├── templates ├── ru │ ├── event_info │ │ ├── no_event.hbs │ │ └── success.hbs │ ├── event_remove │ │ ├── no_event.hbs │ │ └── success.hbs │ ├── player_add │ │ ├── no_event.hbs │ │ ├── already_added.hbs │ │ └── success.hbs │ ├── player_remove │ │ ├── no_event.hbs │ │ ├── no_player.hbs │ │ └── success.hbs │ └── event_add │ │ ├── invalid_date_past.hbs │ │ ├── invalid_date.hbs │ │ └── success.hbs └── en │ ├── event_info │ ├── no_event.hbs │ └── success.hbs │ ├── event_remove │ ├── no_event.hbs │ └── success.hbs │ ├── player_add │ ├── no_event.hbs │ ├── already_added.hbs │ └── success.hbs │ ├── player_remove │ ├── no_event.hbs │ ├── no_player.hbs │ └── success.hbs │ └── event_add │ ├── invalid_date_past.hbs │ ├── invalid_date.hbs │ └── success.hbs ├── src ├── common │ ├── constants.ts │ ├── event-bus.service.ts │ ├── utils.ts │ ├── common.module.ts │ ├── config.service.ts │ └── template.service.ts ├── ping.controller.ts ├── message │ ├── i-message.ts │ └── base.message.ts ├── actions │ ├── statuses.ts │ ├── actions.module.ts │ ├── player.helper.ts │ ├── event_remove.action.ts │ ├── event_add.action.ts │ ├── event_info.action.ts │ ├── player_add.action.ts │ ├── player_remove.action.ts │ └── base.action.ts ├── storage │ ├── storage.module.ts │ ├── models │ │ ├── player.ts │ │ ├── chat.ts │ │ └── event.ts │ ├── db-connection.ts │ └── storage.service.ts ├── app.module.ts ├── telegram │ ├── telegram.module.ts │ ├── telegram.message.ts │ └── telegram.service.ts ├── main.ts └── vk │ ├── vk.module.ts │ ├── vk-callback.controller.ts │ ├── vk.message.ts │ └── vk.service.ts ├── .prettierrc ├── tsconfig.build.json ├── .gitignore ├── .dockerignore ├── Dockerfile ├── .travis.yml ├── test ├── stubs │ ├── template.service.stub.ts │ ├── context.stub.ts │ └── actions.module.stub.ts ├── helpers │ └── db-helper.ts ├── actions │ ├── event_remove.action.spec.ts │ ├── event_info.action.spec.ts │ ├── event_add.action.spec.ts │ ├── player_remove.action.spec.ts │ └── player_add.action.spec.ts └── integration │ └── scenarios.spec.ts ├── tsconfig.json ├── .nycrc ├── tslint.json ├── git ├── commitlint.js └── commitizen.js ├── SECURITY.md ├── .github └── workflows │ ├── dockerimage.yml │ └── snyk_container-analysis.yml ├── LICENSE ├── README.md └── package.json /templates/ru/event_info/no_event.hbs: -------------------------------------------------------------------------------- 1 | 🚫 Нет назначенных игр 🚫 -------------------------------------------------------------------------------- /templates/ru/event_remove/no_event.hbs: -------------------------------------------------------------------------------- 1 | 🚫 Нет назначенных игр 🚫 -------------------------------------------------------------------------------- /templates/ru/player_add/no_event.hbs: -------------------------------------------------------------------------------- 1 | 🚫 Нет назначенных игр 🚫 -------------------------------------------------------------------------------- /templates/ru/player_remove/no_event.hbs: -------------------------------------------------------------------------------- 1 | 🚫 Нет назначенных игр 🚫 -------------------------------------------------------------------------------- /templates/en/event_info/no_event.hbs: -------------------------------------------------------------------------------- 1 | 🚫 No active events were found 🚫 -------------------------------------------------------------------------------- /templates/en/event_remove/no_event.hbs: -------------------------------------------------------------------------------- 1 | 🚫 No active events were found 🚫 -------------------------------------------------------------------------------- /templates/en/player_add/no_event.hbs: -------------------------------------------------------------------------------- 1 | 🚫 No active events were found 🚫 -------------------------------------------------------------------------------- /templates/en/player_remove/no_event.hbs: -------------------------------------------------------------------------------- 1 | 🚫 No active events were found 🚫 -------------------------------------------------------------------------------- /templates/ru/player_remove/no_player.hbs: -------------------------------------------------------------------------------- 1 | 🚷 Игрок {{name}} не найден 🚷 -------------------------------------------------------------------------------- /templates/en/player_remove/no_player.hbs: -------------------------------------------------------------------------------- 1 | 🚷 Player {{name}} was not found 🚷 -------------------------------------------------------------------------------- /templates/ru/event_remove/success.hbs: -------------------------------------------------------------------------------- 1 | ⚽️🚫 Игра {{date}} была отменена 🚫⚽️ -------------------------------------------------------------------------------- /templates/ru/player_add/already_added.hbs: -------------------------------------------------------------------------------- 1 | 🚷 Игрок {{name}} уже есть в списке 🚷 -------------------------------------------------------------------------------- /templates/en/event_remove/success.hbs: -------------------------------------------------------------------------------- 1 | ⚽️🚫 Event on {{date}} has been canceled 🚫⚽️ -------------------------------------------------------------------------------- /templates/en/player_add/already_added.hbs: -------------------------------------------------------------------------------- 1 | 🚷 Player {{name}} has been already invited 🚷 -------------------------------------------------------------------------------- /src/common/constants.ts: -------------------------------------------------------------------------------- 1 | export enum VK_MODES { 2 | LONG_POLLING = 'polling', 3 | CALLBACK = 'callback' 4 | } -------------------------------------------------------------------------------- /templates/en/event_add/invalid_date_past.hbs: -------------------------------------------------------------------------------- 1 | ❗️Invalid date❗️ 2 | 3 | 🕰 Event date must be in future -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "tabWidth": 4, 5 | "bracketSpacing": false, 6 | } -------------------------------------------------------------------------------- /templates/ru/event_add/invalid_date_past.hbs: -------------------------------------------------------------------------------- 1 | ❗️Неправильная дата❗️ 2 | 3 | 🕰 Дата события должна быть в будущем -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /templates/en/event_add/invalid_date.hbs: -------------------------------------------------------------------------------- 1 | ❗️Invalid date format❗️ 2 | 3 | 🕰 Allowed date example: 2019-12-06 09:30 -------------------------------------------------------------------------------- /templates/ru/event_add/invalid_date.hbs: -------------------------------------------------------------------------------- 1 | ❗️Неправильный формат даты❗️ 2 | 3 | 🕰 Допустимый формат: 2019-12-06 09:30 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .history 2 | .idea 3 | *.env 4 | !.env.dist 5 | *.sqlite 6 | .nyc_output 7 | node_modules 8 | dist 9 | coverage 10 | 11 | article.md -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .history 3 | .vscode 4 | *.sqlite 5 | git 6 | node_modules 7 | npm-debug.log 8 | coverage 9 | src 10 | test 11 | LICENSE 12 | README.md 13 | ts*.json -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm ci --only=production 8 | 9 | COPY . . 10 | 11 | EXPOSE 3000 12 | 13 | CMD [ "node", "dist/main.js" ] -------------------------------------------------------------------------------- /templates/en/event_info/success.hbs: -------------------------------------------------------------------------------- 1 | ⚽️🗓 Event Info Date: {{date}} 2 | 3 | ℹ️ List of players: 4 | {{#each players}} 5 | {{index}}: {{name}} 6 | {{/each}} 7 | 8 | Total: {{total}} -------------------------------------------------------------------------------- /templates/ru/event_info/success.hbs: -------------------------------------------------------------------------------- 1 | 🗓 Информация о игре Дата: {{date}} 2 | 3 | ℹ️ Список игроков: 4 | {{#each players}} 5 | {{index}}: {{name}} 6 | {{/each}} 7 | 8 | Итого: {{total}} -------------------------------------------------------------------------------- /src/ping.controller.ts: -------------------------------------------------------------------------------- 1 | import {Controller, Get} from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class PingController { 5 | @Get('/ping') 6 | getHello(): string { 7 | return 'ok'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /templates/ru/player_add/success.hbs: -------------------------------------------------------------------------------- 1 | ✅ Игрок {{name}} примет участие в игре ✅ 2 | 3 | ℹ️ Список игроков: 4 | {{#each players}} 5 | {{index}}: {{name}} 6 | {{/each}} 7 | 8 | Итого: {{total}} -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | - '12' 5 | - '14' 6 | script: 7 | - npm run lint 8 | - npm run build 9 | - npm run test:cov 10 | after_script: 11 | - npm install -g codecov 12 | - codecov -------------------------------------------------------------------------------- /templates/en/player_add/success.hbs: -------------------------------------------------------------------------------- 1 | ✅ Player {{name}} will take part in the game ✅ 2 | 3 | ℹ️ List of players: 4 | {{#each players}} 5 | {{index}}: {{name}} 6 | {{/each}} 7 | 8 | Total: {{total}} -------------------------------------------------------------------------------- /templates/en/player_remove/success.hbs: -------------------------------------------------------------------------------- 1 | ❌ Player {{name}} canceled his participation ❌ 2 | 3 | ℹ️ List of players: 4 | {{#each players}} 5 | {{index}}: {{name}} 6 | {{/each}} 7 | 8 | Total: {{total}} -------------------------------------------------------------------------------- /templates/ru/player_remove/success.hbs: -------------------------------------------------------------------------------- 1 | ❌ Игрок {{name}} отменил свое участие в игре ❌ 2 | 3 | ℹ️ Список игроков: 4 | {{#each players}} 5 | {{index}}: {{name}} 6 | {{/each}} 7 | 8 | Итого: {{total}} -------------------------------------------------------------------------------- /test/stubs/template.service.stub.ts: -------------------------------------------------------------------------------- 1 | import {IParams} from '../../src/common/template.service'; 2 | 3 | export class TemplateServiceStub { 4 | public apply(params: IParams, data: any): string { 5 | return JSON.stringify({params, data}); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /templates/ru/event_add/success.hbs: -------------------------------------------------------------------------------- 1 | ⚽️Новая игра⚽️ 2 | 3 | 🗓 Дата: {{date}} 4 | 5 | 1️⃣ Принять участие: /add 6 | 2️⃣ Пригласить другого: /add username 7 | 3️⃣ Отменить свою заявку на участие: /remove 8 | 4️⃣ Посмотреть список участников: /info 9 | -------------------------------------------------------------------------------- /templates/en/event_add/success.hbs: -------------------------------------------------------------------------------- 1 | ⚽️New Event is coming⚽️ 2 | 3 | 🗓 Date: {{date}} 4 | 5 | 1️⃣ Apply to upcoming event: /add 6 | 2️⃣ Invite player: /add username 7 | 3️⃣ Cancel your invitation request: /remove 8 | 4️⃣ View information about upcoming event: /info -------------------------------------------------------------------------------- /src/message/i-message.ts: -------------------------------------------------------------------------------- 1 | export interface IMessage { 2 | chatId: number; 3 | lang: string; 4 | text: string; 5 | fullText: string; 6 | command: string; 7 | name: string; 8 | getReplyStatus: () => string; 9 | getReplyData: () => any; 10 | setStatus: (status: string) => IMessage; 11 | withData: (data: any) => IMessage; 12 | answer: (args: any) => any; 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true 13 | }, 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /src/actions/statuses.ts: -------------------------------------------------------------------------------- 1 | export const STATUS_SUCCESS: string = 'success'; 2 | export const STATUS_FAIL: string = 'fail'; 3 | export const STATUS_NO_EVENT: string = 'no_event'; 4 | export const STATUS_ALREADY_ADDED: string = 'already_added'; 5 | export const STATUS_NO_PLAYER: string = 'no_player'; 6 | export const STATUS_INVALID_DATE: string = 'invalid_date'; 7 | export const STATUS_INVALID_DATE_PAST: string = 'invalid_date_past'; 8 | -------------------------------------------------------------------------------- /src/storage/storage.module.ts: -------------------------------------------------------------------------------- 1 | import {Module} from '@nestjs/common'; 2 | 3 | import {CommonModule} from '../common/common.module'; 4 | import {getConnection} from './db-connection'; 5 | import {StorageService} from './storage.service'; 6 | 7 | @Module({ 8 | imports: [CommonModule, getConnection()], 9 | providers: [StorageService], 10 | exports: [StorageService], 11 | }) 12 | export class StorageModule {} 13 | -------------------------------------------------------------------------------- /src/common/event-bus.service.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | 3 | export class AppEmitter extends EventEmitter { 4 | public readonly EVENT_ADD: string = 'event_add'; 5 | public readonly EVENT_INFO: string = 'event_info'; 6 | public readonly EVENT_REMOVE: string = 'event_remove'; 7 | public readonly PLAYER_ADD: string = 'player_add'; 8 | public readonly PLAYER_REMOVE: string = 'player_remove'; 9 | } 10 | -------------------------------------------------------------------------------- /src/storage/models/player.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | ManyToOne, 6 | JoinColumn, 7 | } from 'typeorm'; 8 | import {Event} from './event'; 9 | 10 | @Entity() 11 | export class Player { 12 | @PrimaryGeneratedColumn() 13 | id: number; 14 | 15 | @Column() 16 | name: string; 17 | 18 | @ManyToOne(type => Event, event => event.players) 19 | @JoinColumn() 20 | event: Event; 21 | } 22 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "cache": false, 3 | "check-coverage": false, 4 | "extension": [ 5 | ".ts" 6 | ], 7 | "include": [ 8 | "src/**/*.ts" 9 | ], 10 | "exclude": [ 11 | "coverage/**", 12 | "node_modules/**", 13 | "**/*.d.ts", 14 | "**/*.spec.ts" 15 | ], 16 | "report-dir": "./coverage", 17 | "sourceMap": true, 18 | "reporter": [ 19 | "html", 20 | "text", 21 | "text-summary", 22 | "lcov" 23 | ], 24 | "all": true, 25 | "instrument": true 26 | } -------------------------------------------------------------------------------- /src/storage/models/chat.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | OneToMany, 6 | JoinColumn, 7 | Index, 8 | } from 'typeorm'; 9 | import {Event} from './event'; 10 | 11 | @Entity() 12 | export class Chat { 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @Index({unique: true}) 17 | @Column() 18 | chatId: number; 19 | 20 | @OneToMany(type => Event, event => event.chat) 21 | @JoinColumn() 22 | events: Event[]; 23 | } 24 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TelegramModule } from './telegram/telegram.module'; 3 | import { VKModule } from './vk/vk.module'; 4 | import { ActionsModule } from './actions/actions.module'; 5 | import { PingController } from './ping.controller'; 6 | 7 | @Module({ 8 | imports: [ 9 | TelegramModule, 10 | VKModule, 11 | ActionsModule 12 | ], 13 | controllers: [PingController], 14 | providers: [], 15 | }) 16 | export class AppModule { } 17 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | import * as moment from 'moment'; 2 | 3 | export const parseEventDate = (str: string = '') => { 4 | str = str.trim(); 5 | 6 | return moment(str).isValid() ? moment.utc(str).toDate() : null; 7 | }; 8 | 9 | export const isDateInPast = (date: Date) => { 10 | const currentDate = moment.utc(); 11 | return moment.utc(date).isBefore(currentDate); 12 | }; 13 | 14 | export const formatEventDate = (date: Date | string): string => { 15 | return moment.utc(date).format('DD-MM-YYYY HH:mm'); 16 | }; 17 | -------------------------------------------------------------------------------- /src/telegram/telegram.module.ts: -------------------------------------------------------------------------------- 1 | import {Module, OnModuleInit} from '@nestjs/common'; 2 | import {CommonModule} from '../common/common.module'; 3 | import {TelegramService} from './telegram.service'; 4 | 5 | @Module({ 6 | imports: [CommonModule], 7 | providers: [TelegramService], 8 | exports: [TelegramService], 9 | }) 10 | export class TelegramModule implements OnModuleInit { 11 | constructor(private readonly telegramService: TelegramService) {} 12 | 13 | async onModuleInit() { 14 | await this.telegramService.launch(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/helpers/db-helper.ts: -------------------------------------------------------------------------------- 1 | import {StorageService} from '../../src/storage/storage.service'; 2 | import {Chat} from '../../src/storage/models/chat'; 3 | import {Event} from '../../src/storage/models/event'; 4 | import {Player} from '../../src/storage/models/player'; 5 | 6 | export const clearDatabase = async ( 7 | storageService: StorageService, 8 | ): Promise => { 9 | await storageService.connection.getRepository(Player).clear(); 10 | await storageService.connection.getRepository(Event).clear(); 11 | await storageService.connection.getRepository(Chat).clear(); 12 | }; 13 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 150], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false 16 | }, 17 | "rulesDirectory": [], 18 | "linterOptions": { 19 | "exclude": [ 20 | "git" 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/storage/models/event.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | OneToMany, 6 | ManyToOne, 7 | JoinColumn, 8 | } from 'typeorm'; 9 | import {Chat} from './chat'; 10 | import {Player} from './player'; 11 | 12 | @Entity() 13 | export class Event { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @Column() 18 | date: Date; 19 | 20 | @Column() 21 | active: boolean; 22 | 23 | @ManyToOne(type => Chat, chat => chat.events) 24 | chat: Chat; 25 | 26 | @OneToMany(type => Player, player => player.event) 27 | @JoinColumn() 28 | players: Player[]; 29 | } 30 | -------------------------------------------------------------------------------- /git/commitlint.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'body-leading-blank': [2, 'always'], 4 | 'footer-leading-blank': [2, 'always'], 5 | 'header-max-length': [2, 'always', 72], 6 | 'scope-case': [2, 'always', 'lower-case'], 7 | 'subject-empty': [2, 'never'], 8 | 'subject-full-stop': [2, 'never', '.'], 9 | 'type-case': [2, 'always', 'lower-case'], 10 | 'type-empty': [2, 'never'], 11 | 'type-enum': [ 12 | 2, 13 | 'always', 14 | [ 15 | 'ci', 16 | 'feat', 17 | 'fix', 18 | 'other', 19 | 'revert' 20 | ] 21 | ] 22 | } 23 | }; -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /src/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import {Module, Logger} from '@nestjs/common'; 2 | import {ConfigService} from './config.service'; 3 | import {AppEmitter} from './event-bus.service'; 4 | import {TemplateService} from './template.service'; 5 | 6 | @Module({ 7 | providers: [ 8 | { 9 | provide: ConfigService, 10 | useValue: new ConfigService(), 11 | }, 12 | { 13 | provide: AppEmitter, 14 | useValue: new AppEmitter(), 15 | }, 16 | { 17 | provide: Logger, 18 | useValue: new Logger(), 19 | }, 20 | TemplateService, 21 | ], 22 | exports: [ConfigService, AppEmitter, Logger, TemplateService], 23 | }) 24 | export class CommonModule {} 25 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import * as dotenv from 'dotenv'; 3 | 4 | import {NestFactory} from '@nestjs/core'; 5 | import {AppModule} from './app.module'; 6 | import {CommonModule} from './common/common.module'; 7 | import {ConfigService} from './common/config.service'; 8 | 9 | async function start() { 10 | dotenv.config(); 11 | 12 | const app = await NestFactory.create(AppModule); 13 | const config = app.select(CommonModule).get(ConfigService, {strict: true}); 14 | await app.listen(config.get('PORT')); 15 | 16 | process.on('unhandledRejection', (reason, promise) => { 17 | // tslint:disable-next-line: no-console 18 | console.error('Unhandled Rejection at:', promise, 'reason:', reason); 19 | }); 20 | } 21 | 22 | start(); 23 | -------------------------------------------------------------------------------- /.github/workflows/dockerimage.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | env: 14 | LOGIN: ${{ secrets.DOCKER_LOGIN }} 15 | NAME: ${{ secrets.DOCKER_NAME }} 16 | 17 | steps: 18 | - uses: actions/checkout@v1 19 | - name: Install Node.js 20 | uses: actions/setup-node@v1 21 | - name: Login to docker.io 22 | run: echo ${{ secrets.DOCKER_PWD }} | docker login -u ${{ secrets.DOCKER_LOGIN }} --password-stdin 23 | - name: npm install and build 24 | run: npm ci && npm run build 25 | - name: Build the Docker image 26 | run: docker build -t $LOGIN/$NAME:${GITHUB_REF:10} -f Dockerfile . 27 | - name: Push image to docker.io 28 | run: docker push $LOGIN/$NAME:${GITHUB_REF:10} 29 | -------------------------------------------------------------------------------- /src/actions/actions.module.ts: -------------------------------------------------------------------------------- 1 | import {Module} from '@nestjs/common'; 2 | import {CommonModule} from '../common/common.module'; 3 | import {StorageModule} from '../storage/storage.module'; 4 | 5 | import {PlayerHelper} from './player.helper'; 6 | import {EventAddAction} from './event_add.action'; 7 | import {EventInfoAction} from './event_info.action'; 8 | import {EventRemoveAction} from './event_remove.action'; 9 | import {PlayerAddAction} from './player_add.action'; 10 | import {PlayerRemoveAction} from './player_remove.action'; 11 | 12 | @Module({ 13 | imports: [CommonModule, StorageModule], 14 | providers: [ 15 | EventAddAction, 16 | EventInfoAction, 17 | EventRemoveAction, 18 | PlayerAddAction, 19 | PlayerRemoveAction, 20 | PlayerHelper, 21 | ], 22 | exports: [], 23 | }) 24 | export class ActionsModule {} 25 | -------------------------------------------------------------------------------- /src/storage/db-connection.ts: -------------------------------------------------------------------------------- 1 | import {TypeOrmModule} from '@nestjs/typeorm'; 2 | import {DynamicModule} from '@nestjs/common'; 3 | 4 | const commonOptions = { 5 | entities: [__dirname + '/models/*{.ts,.js}'], 6 | synchronize: true, 7 | logging: false, 8 | }; 9 | 10 | const connections = { 11 | development: { 12 | type: 'sqlite', 13 | database: 'development.sqlite', 14 | ...commonOptions, 15 | }, 16 | test: { 17 | type: 'sqlite', 18 | database: 'test.sqlite', 19 | ...commonOptions, 20 | }, 21 | production: { 22 | type: 'postgres', 23 | url: process.env.DATABASE_URL, 24 | ...commonOptions, 25 | }, 26 | }; 27 | 28 | export const getConnection = (): DynamicModule => { 29 | const env: string = process.env.NODE_ENV || 'development'; 30 | const connectionOptions = connections[env]; 31 | 32 | return TypeOrmModule.forRoot(connectionOptions); 33 | }; 34 | -------------------------------------------------------------------------------- /src/vk/vk.module.ts: -------------------------------------------------------------------------------- 1 | import {Logger, Module, OnModuleInit} from '@nestjs/common'; 2 | import { ConfigService } from 'src/common/config.service'; 3 | import { VK_MODES } from 'src/common/constants'; 4 | import {CommonModule} from '../common/common.module'; 5 | import { VKCallbackController } from './vk-callback.controller'; 6 | import {VKService} from './vk.service'; 7 | 8 | @Module({ 9 | imports: [CommonModule], 10 | controllers: [VKCallbackController], 11 | providers: [VKService], 12 | exports: [VKService], 13 | }) 14 | export class VKModule implements OnModuleInit { 15 | constructor( 16 | private readonly logger: Logger, 17 | private readonly config: ConfigService, 18 | private readonly vkService: VKService 19 | ) {} 20 | 21 | onModuleInit() { 22 | if (this.config.get('VK_MODE') === VK_MODES.LONG_POLLING) { 23 | this.vkService.launch(); 24 | } else { 25 | this.logger.warn('Long-Polling API disabled. Only callbacks allowed') 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/vk/vk-callback.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Logger, Next, Post, Req, Res } from '@nestjs/common'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | import { ConfigService } from 'src/common/config.service'; 4 | import { VK_MODES } from 'src/common/constants'; 5 | import { VKService } from './vk.service'; 6 | 7 | @Controller() 8 | export class VKCallbackController { 9 | constructor( 10 | private readonly logger: Logger, 11 | private readonly config: ConfigService, 12 | private readonly vkService: VKService 13 | ) {} 14 | 15 | @Post('/vk/callback') 16 | public async handleCallback(@Req() request: Request, @Res() response: Response, @Next() next: NextFunction) { 17 | if (this.config.get('VK_MODE') === VK_MODES.CALLBACK) { 18 | this.vkService.getBot().webhookCallback(request, response, next); 19 | } else { 20 | this.logger.warn('Callback API disabled. Only long-polling allowed') 21 | return 'Callback API disabled'; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/actions/player.helper.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common'; 2 | import {StorageService} from '../storage/storage.service'; 3 | import {Event} from '../storage/models/event'; 4 | import {Player} from '../storage/models/player'; 5 | import {IMessage} from '../message/i-message'; 6 | 7 | @Injectable() 8 | export class PlayerHelper { 9 | protected storageService: StorageService; 10 | 11 | constructor(storageService: StorageService) { 12 | this.storageService = storageService; 13 | } 14 | 15 | public getPlayerName(message: IMessage) { 16 | const name = message.text.trim(); 17 | return name.length > 0 ? name : message.name; 18 | } 19 | 20 | public async getPlayersList(event: Event) { 21 | const players: Player[] = await this.storageService.getPlayers(event); 22 | 23 | return { 24 | total: players.length, 25 | players: players.map((player, index) => ({ 26 | index: index + 1, 27 | name: player.name, 28 | })), 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /git/commitizen.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | types: [ 5 | { value: "ci", name: "ci: CI settings" }, 6 | { value: "feat", name: "feat: Add new feature" }, 7 | { value: "fix", name: "fix: Issue fix" }, 8 | { value: "other", name: "other: Other changes"}, 9 | { value: "revert", name: "revert: Revert to previous code version" } 10 | ], 11 | 12 | scopes: [ 13 | { name: "source" }, 14 | { name: "tests" } 15 | ], 16 | 17 | scopeOverrides: { 18 | content: [ 19 | {name: 'source'}, 20 | ], 21 | tests: [ 22 | {name: 'tests'} 23 | ] 24 | }, 25 | 26 | messages: { 27 | type: "What changes do you make ?", 28 | scope: "Select scope which your changes are related to:", 29 | customScope: "Select you scope (optional):", 30 | subject: "Write commit subject:", 31 | body: 'Write detailed commit description:', 32 | confirmCommit: "Do you satisfied by given result ?" 33 | }, 34 | 35 | allowCustomScopes: true, 36 | allowBreakingChanges: false, 37 | footerPrefix: "Meta:", 38 | subjectLimit: 120 39 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Andrey Kuznetsov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/vk/vk.message.ts: -------------------------------------------------------------------------------- 1 | import {BaseMessage} from '../message/base.message'; 2 | import {IMessage} from '../message/i-message'; 3 | 4 | export class VKMessage extends BaseMessage implements IMessage { 5 | private ctx: any; 6 | 7 | constructor(ctx) { 8 | super(); 9 | 10 | this.ctx = ctx; 11 | 12 | const {message} = this.ctx; 13 | this.chatId = this.getChatId(this.ctx); 14 | this.fullText = message.text; 15 | this.command = this.ctx.command; 16 | this.text = this.fullText.replace(`/${this.command}`, ''); 17 | this.lang = 'ru'; 18 | this.firstName = message.from.first_name; 19 | this.lastName = message.from.last_name; 20 | } 21 | 22 | public answer(args: any) { 23 | const answer: string = `${args}`.replace(/<\/?(strong|i)>/gm, ''); 24 | this.ctx.reply(answer); 25 | } 26 | 27 | private getChatId({message, group_id, bot}): number { 28 | const peerId: number = +`${message.peer_id}`.replace(/[0-9]0+/, ''); 29 | const groupId: number = group_id || bot.settings.group_id; 30 | return peerId + groupId; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/common/config.service.ts: -------------------------------------------------------------------------------- 1 | import * as envalid from 'envalid'; 2 | import { VK_MODES } from './constants'; 3 | 4 | export class ConfigService { 5 | private readonly envConfig: Record; 6 | 7 | constructor() { 8 | this.envConfig = envalid.cleanEnv(process.env, { 9 | PORT: envalid.port({default: 3000}), 10 | TELEGRAM_BOT_TOKEN: envalid.str(), 11 | TELEGRAM_USE_PROXY: envalid.bool({default: false}), 12 | TELEGRAM_PROXY_HOST: envalid.host(), 13 | TELEGRAM_PROXY_PORT: envalid.port(), 14 | TELEGRAM_PROXY_LOGIN: envalid.str(), 15 | TELEGRAM_PROXY_PASSWORD: envalid.str(), 16 | VK_TOKEN: envalid.str(), 17 | VK_CONFIRMATION: envalid.str(), 18 | VK_MODE: envalid.str({choices: [VK_MODES.LONG_POLLING, VK_MODES.CALLBACK]}) 19 | }); 20 | } 21 | 22 | /** 23 | * 24 | * Returns value from config by it key 25 | * @param {string} key 26 | * @returns {string} 27 | * 28 | * @memberOf ConfigService 29 | */ 30 | public get(key: string): string { 31 | return this.envConfig[key]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/stubs/context.stub.ts: -------------------------------------------------------------------------------- 1 | import {TelegramMessage} from '../../src/telegram/telegram.message'; 2 | import moment = require('moment'); 3 | 4 | export const createContextStub = (params: any, callback) => { 5 | const { 6 | lang = 'en', 7 | chatId = 1, 8 | firstName, 9 | lastName, 10 | text = '', 11 | command = '', 12 | } = params; 13 | 14 | return new TelegramMessage({ 15 | command, 16 | update: { 17 | message: { 18 | chat: { 19 | id: chatId, 20 | }, 21 | from: { 22 | language_code: lang, 23 | first_name: firstName, 24 | last_name: lastName, 25 | }, 26 | text, 27 | }, 28 | }, 29 | replyWithHTML: (...args) => callback(...args), 30 | }); 31 | }; 32 | 33 | export const createEventAddContextStub = (params: any, callback) => { 34 | const defaultDate = moment.utc().add(2, 'days').format('YYYY-MM-DD HH:mm'); 35 | 36 | params.command = 'event_add'; 37 | params.text = params.text || `/event_add ${defaultDate}`; 38 | 39 | return createContextStub(params, callback); 40 | }; 41 | -------------------------------------------------------------------------------- /src/actions/event_remove.action.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common'; 2 | 3 | import * as statuses from './statuses'; 4 | import {formatEventDate} from '../common/utils'; 5 | import {BaseAction} from './base.action'; 6 | import {Chat} from '../storage/models/chat'; 7 | import {Event} from '../storage/models/event'; 8 | import {IMessage} from '../message/i-message'; 9 | 10 | @Injectable() 11 | export class EventRemoveAction extends BaseAction { 12 | protected setEvent(): void { 13 | this.event = this.appEmitter.EVENT_REMOVE; 14 | } 15 | 16 | protected async doAction(chat: Chat, message: IMessage): Promise { 17 | const activeEvent: Event = await this.storageService.findChatActiveEvent( 18 | chat, 19 | ); 20 | await this.storageService.markChatEventsInactive(chat); 21 | 22 | if (activeEvent) { 23 | this.logger.log(`Active event with id=${activeEvent.id} date=${activeEvent.date} for chat ${chat.id} was removed`); 24 | 25 | return message.setStatus(statuses.STATUS_SUCCESS).withData({ 26 | date: formatEventDate(activeEvent.date), 27 | }); 28 | } else { 29 | return message.setStatus(statuses.STATUS_NO_EVENT); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/stubs/actions.module.stub.ts: -------------------------------------------------------------------------------- 1 | import {Test, TestingModule} from '@nestjs/testing'; 2 | 3 | import {CommonModule} from '../../src/common/common.module'; 4 | import {StorageModule} from '../../src/storage/storage.module'; 5 | import {TemplateService} from '../../src/common/template.service'; 6 | import {TemplateServiceStub} from './template.service.stub'; 7 | 8 | import {EventAddAction} from '../../src/actions/event_add.action'; 9 | import {EventRemoveAction} from '../../src/actions/event_remove.action'; 10 | import {EventInfoAction} from '../../src/actions/event_info.action'; 11 | import {PlayerAddAction} from '../../src/actions/player_add.action'; 12 | import {PlayerRemoveAction} from '../../src/actions/player_remove.action'; 13 | import {PlayerHelper} from '../../src/actions/player.helper'; 14 | 15 | let moduleStub: TestingModule; 16 | 17 | export const createModuleStub = async (): Promise => { 18 | moduleStub = 19 | moduleStub || 20 | (await Test.createTestingModule({ 21 | imports: [CommonModule, StorageModule], 22 | providers: [ 23 | EventAddAction, 24 | EventRemoveAction, 25 | EventInfoAction, 26 | PlayerAddAction, 27 | PlayerRemoveAction, 28 | PlayerHelper, 29 | ], 30 | }) 31 | .overrideProvider(TemplateService) 32 | .useClass(TemplateServiceStub) 33 | .compile()); 34 | 35 | return moduleStub; 36 | }; 37 | -------------------------------------------------------------------------------- /src/actions/event_add.action.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common'; 2 | 3 | import * as statuses from './statuses'; 4 | import {parseEventDate, isDateInPast, formatEventDate} from '../common/utils'; 5 | import {BaseAction} from './base.action'; 6 | import {Chat} from '../storage/models/chat'; 7 | import {Event} from '../storage/models/event'; 8 | import {IMessage} from '../message/i-message'; 9 | 10 | @Injectable() 11 | export class EventAddAction extends BaseAction { 12 | protected setEvent(): void { 13 | this.event = this.appEmitter.EVENT_ADD; 14 | } 15 | 16 | protected async doAction(chat: Chat, message: IMessage): Promise { 17 | const eventDate: Date = parseEventDate(message.text.trim()); 18 | 19 | if (!eventDate) { 20 | return message.setStatus(statuses.STATUS_INVALID_DATE); 21 | } 22 | 23 | if (isDateInPast(eventDate)) { 24 | this.logger.warn(`Date of event ${eventDate} was given as date from past`); 25 | return message.setStatus(statuses.STATUS_INVALID_DATE_PAST); 26 | } 27 | 28 | await this.storageService.markChatEventsInactive(chat); 29 | const event: Event = await this.storageService.appendChatActiveEvent( 30 | chat, 31 | eventDate, 32 | ); 33 | 34 | this.logger.log(`New active event with date=${eventDate} for chat ${chat.id} was created`); 35 | 36 | return message.setStatus(statuses.STATUS_SUCCESS).withData({ 37 | date: formatEventDate(event.date), 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/snyk_container-analysis.yml: -------------------------------------------------------------------------------- 1 | # A sample workflow which checks out the code, builds a container 2 | # image using Docker and scans that image for vulnerabilities using 3 | # Snyk. The results are then uploaded to GitHub Security Code Scanning 4 | # 5 | # For more examples, including how to limit scans to only high-severity 6 | # issues, monitor images for newly disclosed vulnerabilities in Snyk and 7 | # fail PR checks for new vulnerabilities, see https://github.com/snyk/actions/ 8 | 9 | name: Snyk Container 10 | on: push 11 | jobs: 12 | snyk: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build a Docker image 17 | run: docker build -t your/image-to-test . 18 | - name: Run Snyk to check Docker image for vulnerabilities 19 | # Snyk can be used to break the build when it detects vulnerabilities. 20 | # In this case we want to upload the issues to GitHub Code Scanning 21 | continue-on-error: true 22 | uses: snyk/actions/docker@master 23 | env: 24 | # In order to use the Snyk Action you will need to have a Snyk API token. 25 | # More details in https://github.com/snyk/actions#getting-your-snyk-token 26 | # or you can signup for free at https://snyk.io/login 27 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 28 | with: 29 | image: your/image-to-test 30 | args: --file=Dockerfile 31 | - name: Upload result to GitHub Code Scanning 32 | uses: github/codeql-action/upload-sarif@v2 33 | with: 34 | sarif_file: snyk.sarif 35 | -------------------------------------------------------------------------------- /src/vk/vk.service.ts: -------------------------------------------------------------------------------- 1 | import * as VkBot from 'node-vk-bot-api'; 2 | 3 | import {Injectable} from '@nestjs/common'; 4 | import {ConfigService} from '../common/config.service'; 5 | import {AppEmitter} from '../common/event-bus.service'; 6 | import {VKMessage} from './vk.message'; 7 | 8 | @Injectable() 9 | export class VKService { 10 | private bot: VkBot; 11 | 12 | constructor(config: ConfigService, appEmitter: AppEmitter) { 13 | const token: string = config.get('VK_TOKEN'); 14 | const confirmation: string = config.get('VK_CONFIRMATION'); 15 | 16 | this.bot = new VkBot({token, confirmation}); 17 | 18 | this.getCommandEventMapping(appEmitter).forEach(([command, event]) => { 19 | this.bot.command(`/${command}`, async ctx => { 20 | const [from] = await this.bot.execute('users.get', { 21 | user_ids: ctx.message.from_id, 22 | }); 23 | ctx.command = command; 24 | ctx.message.from = from; 25 | appEmitter.emit(event, new VKMessage(ctx)); 26 | }); 27 | }); 28 | } 29 | 30 | public getBot(): VkBot { 31 | return this.bot; 32 | } 33 | 34 | public launch(): void { 35 | this.bot.startPolling(); 36 | } 37 | 38 | /** 39 | * Returns mapping structure that links commands and corresponded events 40 | * @private 41 | * @param {AppEmitter} appEmitter 42 | * @returns {Array<[string, string]>} 43 | * @memberOf VKService 44 | */ 45 | private getCommandEventMapping( 46 | appEmitter: AppEmitter, 47 | ): [string, string][] { 48 | return [ 49 | ['event_add', appEmitter.EVENT_ADD], 50 | ['event_remove', appEmitter.EVENT_REMOVE], 51 | ['info', appEmitter.EVENT_INFO], 52 | ['add', appEmitter.PLAYER_ADD], 53 | ['remove', appEmitter.PLAYER_REMOVE], 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/message/base.message.ts: -------------------------------------------------------------------------------- 1 | import {IMessage} from '../message/i-message'; 2 | 3 | export class BaseMessage implements IMessage { 4 | public chatId: number; 5 | public lang: string; 6 | public text: string; 7 | public fullText: string; 8 | public command: string; 9 | 10 | protected firstName: string; 11 | protected lastName: string; 12 | 13 | protected replyStatus: string; 14 | protected replyData: any; 15 | 16 | /** 17 | * Retunrn name of player 18 | * @readonly 19 | * @type {string} 20 | * @memberOf BaseMessage 21 | */ 22 | get name(): string { 23 | const firstName: string = this.firstName || ''; 24 | const lastName: string = this.lastName || ''; 25 | 26 | return `${firstName} ${lastName}`.trim(); 27 | } 28 | 29 | /** 30 | * Returns status of answer (reply) 31 | * @returns {string} 32 | * @memberOf BaseMessage 33 | */ 34 | public getReplyStatus(): string { 35 | return this.replyStatus; 36 | } 37 | 38 | /** 39 | * Returns data for answer (reply) 40 | * @returns {string} 41 | * @memberOf BaseMessage 42 | */ 43 | public getReplyData(): any { 44 | return this.replyData; 45 | } 46 | 47 | /** 48 | * Sets status of answer 49 | * @param {string} status 50 | * @returns {IMessage} 51 | * 52 | * @memberOf BaseMessage 53 | */ 54 | public setStatus(status: string): IMessage { 55 | this.replyStatus = status; 56 | return this; 57 | } 58 | 59 | /** 60 | * Attaches data for answer 61 | * @param {*} data 62 | * @returns {IMessage} 63 | * @memberOf BaseMessage 64 | */ 65 | public withData(data: any): IMessage { 66 | this.replyData = data; 67 | return this; 68 | } 69 | 70 | /** 71 | * @param {*} args 72 | * @returns {string} 73 | * @memberOf BaseMessage 74 | */ 75 | public answer(args: any): any { 76 | throw new Error('not implemented'); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/telegram/telegram.message.ts: -------------------------------------------------------------------------------- 1 | import { TelegrafContext } from 'telegraf/typings/context'; 2 | import { Message } from 'telegraf/typings/telegram-types'; 3 | import {BaseMessage} from '../message/base.message'; 4 | import {IMessage} from '../message/i-message'; 5 | 6 | export class TelegramMessage extends BaseMessage implements IMessage { 7 | private ctx: any; 8 | 9 | constructor(ctx: any) { 10 | super(); 11 | 12 | this.ctx = ctx; 13 | 14 | const {message} = this.ctx.update; 15 | this.chatId = this.adjustChatId(message.chat.id); 16 | this.fullText = message.text; 17 | this.command = this.ctx.command; 18 | this.text = this.extractText(this.fullText); 19 | 20 | this.lang = message.from.language_code; 21 | this.firstName = message.from.first_name; 22 | this.lastName = message.from.last_name; 23 | } 24 | 25 | public answer(args: any): Promise { 26 | return this.ctx.replyWithHTML(args); 27 | } 28 | 29 | /** 30 | * Telegram chat identifier may be greater or less then max 4 byte integer value 31 | * which is used as type for related column in postgresql 32 | * @private 33 | * @param {number} chatId 34 | * @return number 35 | * @memberof TelegramMessage 36 | */ 37 | private adjustChatId(chatId: number): number { 38 | if (Math.abs(chatId) < 2147483648) { 39 | return chatId; 40 | } 41 | 42 | // tslint:disable-next-line: no-bitwise 43 | return this.adjustChatId(chatId >>> 1) 44 | } 45 | 46 | /** 47 | * Trim command name and bot name e.g @MyBot which can be appear on some devices 48 | * @param fullText 49 | */ 50 | private extractText(fullText: string): string { 51 | let text: string = fullText.replace(`/${this.command}`, ''); 52 | 53 | if (this.ctx.botInfo && this.ctx.botInfo.username) { 54 | text = text.replace(new RegExp(`@?${this.ctx.botInfo.username}`), ''); 55 | } 56 | 57 | return text; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/actions/event_info.action.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, Logger} from '@nestjs/common'; 2 | 3 | import * as statuses from './statuses'; 4 | import {formatEventDate} from '../common/utils'; 5 | import {ConfigService} from '../common/config.service'; 6 | import {AppEmitter} from '../common/event-bus.service'; 7 | import {TemplateService} from '../common/template.service'; 8 | import {StorageService} from '../storage/storage.service'; 9 | import {BaseAction} from './base.action'; 10 | import {PlayerHelper} from './player.helper'; 11 | import {Chat} from '../storage/models/chat'; 12 | import {Event} from '../storage/models/event'; 13 | import {Player} from '../storage/models/player'; 14 | import {IMessage} from '../message/i-message'; 15 | 16 | @Injectable() 17 | export class EventInfoAction extends BaseAction { 18 | private playerHelper: PlayerHelper; 19 | 20 | constructor( 21 | config: ConfigService, 22 | appEmitter: AppEmitter, 23 | logger: Logger, 24 | templateService: TemplateService, 25 | playerHelper: PlayerHelper, 26 | storageService: StorageService, 27 | ) { 28 | super(config, appEmitter, logger, templateService, storageService); 29 | 30 | this.playerHelper = playerHelper; 31 | } 32 | 33 | protected setEvent(): void { 34 | this.event = this.appEmitter.EVENT_INFO; 35 | } 36 | 37 | protected async doAction(chat: Chat, message: IMessage): Promise { 38 | const activeEvent: Event = await this.storageService.findChatActiveEvent( 39 | chat, 40 | ); 41 | 42 | if (!activeEvent) { 43 | this.logger.warn(`No active events for chat with id=${chat.id} were found`); 44 | return message.setStatus(statuses.STATUS_NO_EVENT); 45 | } 46 | 47 | const players: Player[] = await this.storageService.getPlayers( 48 | activeEvent, 49 | ); 50 | 51 | return message.setStatus(statuses.STATUS_SUCCESS).withData({ 52 | date: formatEventDate(activeEvent.date), 53 | ...(await this.playerHelper.getPlayersList(activeEvent)), 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/common/template.service.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import * as handlebars from 'handlebars'; 4 | import {readDirDeepSync} from 'read-dir-deep'; 5 | import {Injectable, Logger} from '@nestjs/common'; 6 | 7 | export interface IParams { 8 | action: string; 9 | status: string; 10 | lang?: string; 11 | } 12 | 13 | @Injectable() 14 | export class TemplateService { 15 | private readonly DEFAULT_LANG: string = 'en'; 16 | private readonly TEMPLATE_PATH: string = 'templates'; 17 | 18 | private logger: Logger; 19 | private templatesMap: Map string>; 20 | 21 | constructor(logger: Logger) { 22 | this.logger = logger; 23 | 24 | this.load(); 25 | } 26 | 27 | public apply(params: IParams, data: any): string { 28 | this.logger.log( 29 | `apply template: ${params.action} ${params.status} ${params.lang}`, 30 | ); 31 | 32 | let template = this.getTemplate(params); 33 | 34 | if (!template) { 35 | params.lang = this.DEFAULT_LANG; 36 | template = this.getTemplate(params); 37 | } 38 | 39 | if (!template) { 40 | throw new Error('template-not-found'); 41 | } 42 | 43 | return template(data); 44 | } 45 | 46 | private getTemplate(params: IParams): (data: any) => string { 47 | const {lang, action, status} = params; 48 | return this.templatesMap.get(this.getTemplateKey(lang, action, status)); 49 | } 50 | 51 | private load() { 52 | const templatesDir: string = path.join( 53 | process.cwd(), 54 | this.TEMPLATE_PATH, 55 | ); 56 | const templateFileNames: string[] = readDirDeepSync(templatesDir); 57 | 58 | this.templatesMap = templateFileNames.reduce((acc, fileName) => { 59 | const template = fs.readFileSync(fileName, {encoding: 'utf-8'}); 60 | 61 | const [, lang, action, status] = fileName 62 | .replace(/\.hbs$/, '') 63 | .split('/'); 64 | return acc.set( 65 | this.getTemplateKey(lang, action, status), 66 | handlebars.compile(template), 67 | ); 68 | }, new Map()); 69 | } 70 | 71 | private getTemplateKey( 72 | lang: string, 73 | action: string, 74 | status: string, 75 | ): string { 76 | return `${lang}-${action}-${status}`; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/actions/player_add.action.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, Logger} from '@nestjs/common'; 2 | 3 | import * as statuses from './statuses'; 4 | import {ConfigService} from '../common/config.service'; 5 | import {AppEmitter} from '../common/event-bus.service'; 6 | import {TemplateService} from '../common/template.service'; 7 | import {StorageService} from '../storage/storage.service'; 8 | import {BaseAction} from './base.action'; 9 | import {PlayerHelper} from './player.helper'; 10 | import {Chat} from '../storage/models/chat'; 11 | import {Event} from '../storage/models/event'; 12 | import {Player} from '../storage/models/player'; 13 | import {IMessage} from '../message/i-message'; 14 | 15 | @Injectable() 16 | export class PlayerAddAction extends BaseAction { 17 | private playerHelper: PlayerHelper; 18 | 19 | constructor( 20 | config: ConfigService, 21 | appEmitter: AppEmitter, 22 | logger: Logger, 23 | templateService: TemplateService, 24 | playerHelper: PlayerHelper, 25 | storageService: StorageService, 26 | ) { 27 | super(config, appEmitter, logger, templateService, storageService); 28 | 29 | this.playerHelper = playerHelper; 30 | } 31 | 32 | protected setEvent(): void { 33 | this.event = this.appEmitter.PLAYER_ADD; 34 | } 35 | 36 | protected async doAction(chat: Chat, message: IMessage): Promise { 37 | const activeEvent: Event = await this.storageService.findChatActiveEvent( 38 | chat, 39 | ); 40 | 41 | if (!activeEvent) { 42 | this.logger.warn(`No active events for chat with id=${chat.id} were found`); 43 | return message.setStatus(statuses.STATUS_NO_EVENT); 44 | } 45 | 46 | const name: string = this.playerHelper.getPlayerName(message); 47 | const existedPlayer: Player = await this.storageService.findPlayer( 48 | activeEvent, 49 | name, 50 | ); 51 | 52 | if (existedPlayer) { 53 | return message 54 | .setStatus(statuses.STATUS_ALREADY_ADDED) 55 | .withData({name}); 56 | } 57 | 58 | const newPlayer: Player = await this.storageService.addPlayer( 59 | activeEvent, 60 | name, 61 | ); 62 | 63 | this.logger.log(`Player with id=${newPlayer.id} name=${newPlayer.name} has been added to event ${activeEvent.id}`); 64 | 65 | return message.setStatus(statuses.STATUS_SUCCESS).withData({ 66 | name: newPlayer.name, 67 | ...(await this.playerHelper.getPlayersList(activeEvent)), 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/actions/player_remove.action.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, Logger} from '@nestjs/common'; 2 | 3 | import * as statuses from './statuses'; 4 | import {ConfigService} from '../common/config.service'; 5 | import {AppEmitter} from '../common/event-bus.service'; 6 | import {TemplateService} from '../common/template.service'; 7 | import {StorageService} from '../storage/storage.service'; 8 | import {BaseAction} from './base.action'; 9 | import {PlayerHelper} from './player.helper'; 10 | import {Chat} from '../storage/models/chat'; 11 | import {Event} from '../storage/models/event'; 12 | import {Player} from '../storage/models/player'; 13 | import {IMessage} from '../message/i-message'; 14 | 15 | @Injectable() 16 | export class PlayerRemoveAction extends BaseAction { 17 | private playerHelper: PlayerHelper; 18 | 19 | constructor( 20 | config: ConfigService, 21 | appEmitter: AppEmitter, 22 | logger: Logger, 23 | templateService: TemplateService, 24 | playerHelper: PlayerHelper, 25 | storageService: StorageService, 26 | ) { 27 | super(config, appEmitter, logger, templateService, storageService); 28 | 29 | this.playerHelper = playerHelper; 30 | } 31 | 32 | protected setEvent(): void { 33 | this.event = this.appEmitter.PLAYER_REMOVE; 34 | } 35 | 36 | protected async doAction(chat: Chat, message: IMessage): Promise { 37 | const activeEvent: Event = await this.storageService.findChatActiveEvent( 38 | chat, 39 | ); 40 | 41 | if (!activeEvent) { 42 | this.logger.warn(`No active events for chat with id=${chat.id} were found`); 43 | return message.setStatus(statuses.STATUS_NO_EVENT); 44 | } 45 | 46 | const name: string = this.playerHelper.getPlayerName(message); 47 | const existedPlayer: Player = await this.storageService.findPlayer( 48 | activeEvent, 49 | name, 50 | ); 51 | 52 | if (!existedPlayer) { 53 | this.logger.warn(`No existed players for event with id=${activeEvent.id} and name=${name} were found`); 54 | return message 55 | .setStatus(statuses.STATUS_NO_PLAYER) 56 | .withData({name}); 57 | } 58 | 59 | await this.storageService.removePlayer(existedPlayer); 60 | 61 | this.logger.log(`Player with id=${existedPlayer.id} name=${existedPlayer.name} has been removed from event ${activeEvent.id}`); 62 | 63 | return message.setStatus(statuses.STATUS_SUCCESS).withData({ 64 | name, 65 | ...(await this.playerHelper.getPlayersList(activeEvent)), 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # football-chat-bot-2 2 | Football chat bot build with NestJS framework 3 | 4 | [![Build Status](https://travis-ci.org/tormozz48/football-chat-bot-2.svg?branch=master)](https://travis-ci.org/tormozz48/football-chat-bot-2) 5 | [![codecov](https://codecov.io/gh/tormozz48/football-chat-bot-2/branch/master/graph/badge.svg)](https://codecov.io/gh/tormozz48/football-chat-bot-2) 6 | 7 | ## Install 8 | 9 | 1. Clone repository to your local filesystem from github: 10 | ```bash 11 | git clone https://github.com/tormozz48/football-chat-bot-2.git 12 | ``` 13 | 2. Install npm dependencies: 14 | ```bash 15 | npm install 16 | ``` 17 | 3. Set up all environment variables. 18 | Also you can create `.env` file and fill them with environment variables described in "Configuration" section. 19 | 20 | 1. Launch application server: 21 | ```bash 22 | npm start 23 | ``` 24 | 25 | ## Configuration 26 | 27 | Configuration is performed by modifying these environment variables: 28 | 29 | * `TELEGRAM_BOT_TOKEN` - unique telegram bot token string 30 | * `TELEGRAM_USE_PROXY` - true/false. If set to true then application will establish connection with telegram server via proxy. 31 | * `TELEGRAM_PROXY_HOST` - proxy server host 32 | * `TELEGRAM_PROXY_PORT` - proxy server port 33 | * `TELEGRAM_PROXY_LOGIN` - proxy auth login 34 | * `TELEGRAM_PROXY_PASSWORD` - proxy auth password 35 | * `VK_TOKEN` - unique vk bot token string 36 | * `DATABASE_URL` - connection url fot database. Used for production environment. 37 | 38 | ## Development 39 | 40 | Configured commands: 41 | * `npm run build` - compile TypeScript source code into js distributive. 42 | * `npm run format` - perform code formatting via prettier tool. 43 | * `npm start` - run application server 44 | * `npm start:dev` - run application in "watch mode". Restart after source code chages. 45 | * `npm start:debug` - run application in both "watch" and "debug" modes. 46 | * `npm start:prod` - run application in production mode. 47 | * `npm run lint` - perform code linting via tslint tool. 48 | * `npm test` - run tests. 49 | * `npm test:watch` - run tests in "watch mode". 50 | * `npm test:cov` - run tests and calculate code coverage. 51 | 52 | ## Third-party software 53 | 54 | * [NestJS](https://docs.nestjs.com/) - is a framework for building efficient, scalable Node.js server-side applications. 55 | * [TypeORM](https://typeorm.io/#/) - TypeORM is an ORM that can run in NodeJS, Browser, Cordova, PhoneGap, Ionic, React Native, NativeScript, Expo, and Electron platforms and can be used with TypeScript and JavaScript (ES5, ES6, ES7, ES8). 56 | * [Telegraf](https://telegraf.js.org/#/) - modern Telegram Bot Framework for Node.js. 57 | * [Handlebars](http://handlebarsjs.com/) - Minimal templating on steroids. 58 | -------------------------------------------------------------------------------- /src/telegram/telegram.service.ts: -------------------------------------------------------------------------------- 1 | import Telegraf from 'telegraf'; 2 | 3 | import {Injectable} from '@nestjs/common'; 4 | import * as SocksAgent from 'socks5-https-client/lib/Agent'; 5 | import {ConfigService} from '../common/config.service'; 6 | import {AppEmitter} from '../common/event-bus.service'; 7 | import {TelegramMessage} from './telegram.message'; 8 | import { User } from 'telegraf/typings/telegram-types'; 9 | import { TelegrafContext } from 'telegraf/typings/context'; 10 | 11 | @Injectable() 12 | export class TelegramService { 13 | private bot: Telegraf; 14 | 15 | constructor(config: ConfigService, appEmitter: AppEmitter) { 16 | const botToken: string = config.get('TELEGRAM_BOT_TOKEN'); 17 | 18 | this.bot = config.get('TELEGRAM_USE_PROXY') 19 | ? new Telegraf(botToken, { 20 | telegram: {agent: this.getProxy(config)}, 21 | }) 22 | : new Telegraf(botToken); 23 | 24 | this.getCommandEventMapping(appEmitter).forEach(([command, event]) => { 25 | this.bot.command(command, (ctx: TelegrafContext & {command?: string}) => { 26 | ctx.command = command; 27 | appEmitter.emit(event, new TelegramMessage(ctx)); 28 | }); 29 | }); 30 | } 31 | 32 | public async launch(): Promise { 33 | const botInfo: User = await this.bot.telegram.getMe(); 34 | if (botInfo) { 35 | this.bot.options.username = botInfo.username 36 | } 37 | 38 | this.bot.launch(); 39 | } 40 | 41 | /** 42 | * Returns proxy instance for telegram bot 43 | * @private 44 | * @param {ConfigService} config 45 | * @returns {SocksAgent} 46 | * @memberOf TelegramService 47 | */ 48 | private getProxy(config: ConfigService): SocksAgent { 49 | return new SocksAgent({ 50 | socksHost: config.get('TELEGRAM_PROXY_HOST'), 51 | socksPort: config.get('TELEGRAM_PROXY_PORT'), 52 | socksUsername: config.get('TELEGRAM_PROXY_LOGIN'), 53 | socksPassword: config.get('TELEGRAM_PROXY_PASSWORD'), 54 | }); 55 | } 56 | 57 | /** 58 | * Returns mapping structure that links commands and corresponded events 59 | * @private 60 | * @param {AppEmitter} appEmitter 61 | * @returns {Array<[string, string]>} 62 | * @memberOf TelegramService 63 | */ 64 | private getCommandEventMapping( 65 | appEmitter: AppEmitter, 66 | ): [string, string][] { 67 | return [ 68 | ['event_add', appEmitter.EVENT_ADD], 69 | ['event_remove', appEmitter.EVENT_REMOVE], 70 | ['info', appEmitter.EVENT_INFO], 71 | ['add', appEmitter.PLAYER_ADD], 72 | ['remove', appEmitter.PLAYER_REMOVE], 73 | ]; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/actions/base.action.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, Logger} from '@nestjs/common'; 2 | import {IMessage} from '../message/i-message'; 3 | import {ConfigService} from '../common/config.service'; 4 | import {AppEmitter} from '../common/event-bus.service'; 5 | import {TemplateService} from '../common/template.service'; 6 | import {StorageService} from '../storage/storage.service'; 7 | import {Chat} from '../storage/models/chat'; 8 | 9 | @Injectable() 10 | export class BaseAction { 11 | protected readonly appEmitter: AppEmitter; 12 | protected readonly config: ConfigService; 13 | protected readonly logger: Logger; 14 | 15 | protected readonly templateService: TemplateService; 16 | protected readonly storageService: StorageService; 17 | 18 | protected event: string; 19 | 20 | constructor( 21 | config: ConfigService, 22 | appEmitter: AppEmitter, 23 | logger: Logger, 24 | templateService: TemplateService, 25 | storageService: StorageService, 26 | ) { 27 | this.config = config; 28 | this.logger = logger; 29 | 30 | this.appEmitter = appEmitter; 31 | this.templateService = templateService; 32 | this.storageService = storageService; 33 | 34 | this.setEvent(); 35 | 36 | this.logger.log(`subscribe on "${this.event}" event`); 37 | this.appEmitter.on(this.event, this.handleEvent.bind(this)); 38 | } 39 | 40 | /** 41 | * Set event for action. 42 | * This method must be overrided in child class 43 | * @protected 44 | * @memberOf BaseAction 45 | */ 46 | protected setEvent(): void { 47 | throw new Error('not implemented'); 48 | } 49 | 50 | /** 51 | * Implements action logic. 52 | * This method must be overrided in child class 53 | * @protected 54 | * @param {Chat} chat 55 | * @param {IMessage} message 56 | * @returns {Promise} 57 | * @memberOf BaseAction 58 | */ 59 | protected async doAction(chat: Chat, message: IMessage): Promise { 60 | throw new Error('not implemented'); 61 | } 62 | 63 | private async handleEvent(message: IMessage) { 64 | try { 65 | this.logger.log(`"${this.event}" event received`); 66 | 67 | const chatId: number = message.chatId; 68 | const chat: Chat = await this.storageService.ensureChat(chatId); 69 | message = await this.doAction(chat, message); 70 | 71 | message.answer( 72 | this.templateService.apply( 73 | { 74 | action: this.event, 75 | status: message.getReplyStatus(), 76 | lang: message.lang, 77 | }, 78 | message.getReplyData(), 79 | ), 80 | ); 81 | } catch (error) { 82 | this.logger.error(error); 83 | message.answer(error.message); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "football-chat-bot-2", 3 | "version": "0.2.6", 4 | "description": "Footbal chat bot build with NestJS framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "build": "nest build", 9 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 10 | "start": "nest start", 11 | "start:dev": "nest start --watch", 12 | "start:debug": "nest start --debug --watch", 13 | "start:prod": "node dist/main", 14 | "lint": "tslint -p tsconfig.json -c tslint.json", 15 | "test": "mocha -r ts-node/register test/**/*.spec.ts", 16 | "test:watch": "npm test -- --watch --extension *.ts", 17 | "test:cov": "nyc npm run test", 18 | "env:generate": "node gen-env.js" 19 | }, 20 | "husky": { 21 | "hooks": { 22 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS -g './git/commitlint.js'" 23 | } 24 | }, 25 | "config": { 26 | "commitizen": { 27 | "path": "node_modules/cz-customizable" 28 | }, 29 | "cz-customizable": { 30 | "config": "./git/commitizen.js" 31 | } 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/tormozz48/football-chat-bot-2.git" 36 | }, 37 | "keywords": [ 38 | "football", 39 | "chat", 40 | "bot", 41 | "telegram", 42 | "vk", 43 | "bot", 44 | "vk-bot", 45 | "vk-api", 46 | "nestjs", 47 | "nodejs", 48 | "typescript", 49 | "typeorm", 50 | "sqlite3", 51 | "postgresql" 52 | ], 53 | "author": "andrey.kuznetsov48@yandex.ru", 54 | "license": "MIT", 55 | "bugs": { 56 | "url": "https://github.com/tormozz48/football-chat-bot-2/issues" 57 | }, 58 | "homepage": "https://github.com/tormozz48/football-chat-bot-2#readme", 59 | "publishConfig": { 60 | "registry": "https://npm.pkg.github.com/" 61 | }, 62 | "dependencies": { 63 | "@nestjs/cli": "^8.2.8", 64 | "@nestjs/common": "^7.6.18", 65 | "@nestjs/core": "^7.6.18", 66 | "@nestjs/platform-express": "^7.6.18", 67 | "@nestjs/typeorm": "^7.1.4", 68 | "@types/dotenv": "^8.2.0", 69 | "@types/express": "^4.17.17", 70 | "@types/handlebars": "^4.1.0", 71 | "@types/moment": "^2.13.0", 72 | "@types/node": "^14.18.37", 73 | "dotenv": "^8.6.0", 74 | "envalid": "^6.0.2", 75 | "events-extra": "^1.0.4", 76 | "handlebars": "^4.7.7", 77 | "handlebars-helpers": "^0.10.0", 78 | "lodash": "^4.17.21", 79 | "moment": "^2.29.2", 80 | "node-vk-bot-api": "github:tormozz48/node-vk-bot-api", 81 | "nyc": "^15.1.0", 82 | "pg": "^8.10.0", 83 | "read-dir-deep": "^7.0.1", 84 | "reflect-metadata": "^0.1.13", 85 | "rimraf": "^3.0.2", 86 | "rxjs": "^6.6.7", 87 | "socks5-https-client": "^1.2.1", 88 | "sql.js": "^1.8.0", 89 | "sqlite3": "^5.1.6", 90 | "telegraf": "^3.38.0", 91 | "ts-node": "^9.0.0", 92 | "tsconfig-paths": "^3.14.2", 93 | "typeorm": "^0.3.12", 94 | "typescript": "^4.9.5" 95 | }, 96 | "devDependencies": { 97 | "@commitlint/cli": "^11.0.0", 98 | "@nestjs/testing": "^7.4.4", 99 | "@types/chai": "^4.2.12", 100 | "@types/mocha": "^8.0.3", 101 | "chai": "^4.2.0", 102 | "commitizen": "^4.2.1", 103 | "cz-customizable": "^6.3.0", 104 | "envdot": "0.0.3", 105 | "husky": "^4.3.0", 106 | "mocha": "^8.1.3", 107 | "prettier": "^2.1.2", 108 | "ts-loader": "^8.0.4", 109 | "tslint": "^6.1.3", 110 | "uuid": "^8.3.0" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /test/actions/event_remove.action.spec.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import {expect} from 'chai'; 3 | 4 | import { 5 | createContextStub, 6 | createEventAddContextStub, 7 | } from '../stubs/context.stub'; 8 | import {createModuleStub} from '../stubs/actions.module.stub'; 9 | import {clearDatabase} from '../helpers/db-helper'; 10 | 11 | import {AppEmitter} from '../../src/common/event-bus.service'; 12 | import {StorageService} from '../../src/storage/storage.service'; 13 | import * as statuses from '../../src/actions/statuses'; 14 | import {Chat} from '../../src/storage/models/chat'; 15 | import {Event} from '../../src/storage/models/event'; 16 | import {IParams} from '../../src/common/template.service'; 17 | 18 | describe('EventRemoveAction', () => { 19 | let appEmitter: AppEmitter; 20 | let storageService: StorageService; 21 | 22 | before(async () => { 23 | const testModule = await createModuleStub(); 24 | 25 | appEmitter = testModule.get(AppEmitter); 26 | storageService = testModule.get(StorageService); 27 | }); 28 | 29 | beforeEach(async () => { 30 | await clearDatabase(storageService); 31 | }); 32 | 33 | describe('handle event', () => { 34 | it('should create chat if it does not exist yet', async () => { 35 | const chatCountBefore: number = await storageService.connection 36 | .getRepository(Chat) 37 | .count(); 38 | expect(chatCountBefore).to.equal(0); 39 | 40 | await new Promise(resolve => { 41 | const ctx = createContextStub({}, resolve); 42 | appEmitter.emit(appEmitter.EVENT_REMOVE, ctx); 43 | }); 44 | 45 | const chatCountAfter: number = await storageService.connection 46 | .getRepository(Chat) 47 | .count(); 48 | expect(chatCountAfter).to.equal(1); 49 | }); 50 | 51 | it('should return no_event response if active event was not found', async () => { 52 | const jsonRes: string = await new Promise(resolve => { 53 | const ctx = createContextStub({}, resolve); 54 | appEmitter.emit(appEmitter.EVENT_REMOVE, ctx); 55 | }); 56 | 57 | const {params}: {params: IParams} = JSON.parse(jsonRes); 58 | expect(params.status).to.equal(statuses.STATUS_NO_EVENT); 59 | }); 60 | 61 | describe('active event exists', () => { 62 | let events: Event[]; 63 | 64 | beforeEach(async () => { 65 | await new Promise(resolve => { 66 | const ctx = createEventAddContextStub({}, resolve); 67 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 68 | }); 69 | }); 70 | 71 | it('should remove active event', async () => { 72 | events = await storageService.connection 73 | .getRepository(Event) 74 | .find({}); 75 | expect(events).to.be.lengthOf(1); 76 | expect(events[0].active).to.equal(true); 77 | 78 | await new Promise(resolve => { 79 | const ctx = createContextStub({}, resolve); 80 | appEmitter.emit(appEmitter.EVENT_REMOVE, ctx); 81 | }); 82 | 83 | events = await storageService.connection 84 | .getRepository(Event) 85 | .find({}); 86 | expect(events).to.be.lengthOf(1); 87 | expect(events[0].active).to.equal(false); 88 | }); 89 | 90 | it('should return success result', async () => { 91 | const jsonRes: string = await new Promise(resolve => { 92 | const ctx = createContextStub({}, resolve); 93 | appEmitter.emit(appEmitter.EVENT_REMOVE, ctx); 94 | }); 95 | 96 | const {params}: {params: IParams} = JSON.parse(jsonRes); 97 | expect(params.status).to.equal(statuses.STATUS_SUCCESS); 98 | }); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /test/actions/event_info.action.spec.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import {expect} from 'chai'; 3 | 4 | import { 5 | createContextStub, 6 | createEventAddContextStub, 7 | } from '../stubs/context.stub'; 8 | import {createModuleStub} from '../stubs/actions.module.stub'; 9 | import {clearDatabase} from '../helpers/db-helper'; 10 | 11 | import {AppEmitter} from '../../src/common/event-bus.service'; 12 | import {StorageService} from '../../src/storage/storage.service'; 13 | import * as statuses from '../../src/actions/statuses'; 14 | import {Chat} from '../../src/storage/models/chat'; 15 | import {IParams} from '../../src/common/template.service'; 16 | 17 | describe('EventAddAction', () => { 18 | let appEmitter: AppEmitter; 19 | let storageService: StorageService; 20 | 21 | before(async () => { 22 | const testModule = await createModuleStub(); 23 | 24 | appEmitter = testModule.get(AppEmitter); 25 | storageService = testModule.get(StorageService); 26 | }); 27 | 28 | beforeEach(async () => { 29 | await clearDatabase(storageService); 30 | }); 31 | 32 | describe('handle event', () => { 33 | it('should create chat if it does not exist yet', async () => { 34 | const chatCountBefore: number = await storageService.connection 35 | .getRepository(Chat) 36 | .count(); 37 | expect(chatCountBefore).to.equal(0); 38 | 39 | await new Promise(resolve => { 40 | const ctx = createContextStub({}, resolve); 41 | appEmitter.emit(appEmitter.EVENT_INFO, ctx); 42 | }); 43 | 44 | const chatCountAfter: number = await storageService.connection 45 | .getRepository(Chat) 46 | .count(); 47 | expect(chatCountAfter).to.equal(1); 48 | }); 49 | 50 | it('should return no_event response if active event was not found', async () => { 51 | const jsonRes: string = await new Promise(resolve => { 52 | const ctx = createContextStub({}, resolve); 53 | appEmitter.emit(appEmitter.EVENT_INFO, ctx); 54 | }); 55 | 56 | const {params}: {params: IParams} = JSON.parse(jsonRes); 57 | expect(params.status).to.equal(statuses.STATUS_NO_EVENT); 58 | }); 59 | 60 | describe('active event exists', () => { 61 | beforeEach(async () => { 62 | await new Promise(resolve => { 63 | const ctx = createEventAddContextStub({}, resolve); 64 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 65 | }); 66 | }); 67 | 68 | it('should return success result if active event was found', async () => { 69 | const jsonRes: string = await new Promise(resolve => { 70 | const ctx = createContextStub({}, resolve); 71 | appEmitter.emit(appEmitter.EVENT_INFO, ctx); 72 | }); 73 | 74 | const {params}: {params: IParams} = JSON.parse(jsonRes); 75 | expect(params.status).to.equal(statuses.STATUS_SUCCESS); 76 | }); 77 | 78 | it('should include date of event into response', async () => { 79 | const jsonRes: string = await new Promise(resolve => { 80 | const ctx = createContextStub({}, resolve); 81 | appEmitter.emit(appEmitter.EVENT_INFO, ctx); 82 | }); 83 | 84 | const {data}: any = JSON.parse(jsonRes); 85 | expect(data.date).to.match(/^\d{2}-\d{2}-\d{4}\s\d{2}:\d{2}$/); 86 | }); 87 | 88 | it('should include list of event members', async () => { 89 | const jsonRes: string = await new Promise(resolve => { 90 | const ctx = createContextStub({}, resolve); 91 | appEmitter.emit(appEmitter.EVENT_INFO, ctx); 92 | }); 93 | 94 | const {data}: any = JSON.parse(jsonRes); 95 | expect(data.total).to.equal(0); 96 | expect(data.players).to.eql([]); 97 | }); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/storage/storage.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common'; 2 | import {InjectConnection} from '@nestjs/typeorm'; 3 | import {Connection, Repository, UpdateResult} from 'typeorm'; 4 | 5 | import {Chat} from './models/chat'; 6 | import {Event} from './models/event'; 7 | import {Player} from './models/player'; 8 | 9 | @Injectable() 10 | export class StorageService { 11 | private chatRepository: Repository; 12 | private eventRepository: Repository; 13 | private playerRepository: Repository; 14 | 15 | constructor(@InjectConnection() private readonly dbConnection: Connection) { 16 | this.chatRepository = this.dbConnection.getRepository(Chat); 17 | this.eventRepository = this.dbConnection.getRepository(Event); 18 | this.playerRepository = this.dbConnection.getRepository(Player); 19 | } 20 | 21 | public get connection() { 22 | return this.dbConnection; 23 | } 24 | 25 | /** 26 | * Finds chat for given chatId 27 | * If chat is not exists then creates it and returns it 28 | * @param {number} chatId 29 | * @returns {Promise} 30 | * @memberOf StorageService 31 | */ 32 | public async ensureChat(chatId: number): Promise { 33 | let chat: Chat = await this.chatRepository.findOne({chatId}); 34 | 35 | if (chat) { 36 | return chat; 37 | } 38 | 39 | chat = new Chat(); 40 | chat.chatId = chatId; 41 | 42 | return this.chatRepository.save(chat); 43 | } 44 | 45 | /** 46 | * Updates all chat events. Makes them inactive 47 | * @param {Chat} chat 48 | * @returns {Promise} 49 | * @memberOf StorageService 50 | */ 51 | public markChatEventsInactive(chat: Chat): Promise { 52 | return this.dbConnection 53 | .createQueryBuilder() 54 | .update(Event) 55 | .set({active: false}) 56 | .where({chat}) 57 | .execute(); 58 | } 59 | 60 | /** 61 | * Creates new active event and append it to chat 62 | * @param {Chat} chat 63 | * @param {Date} date 64 | * @returns {Promise} 65 | * @memberOf StorageService 66 | */ 67 | public appendChatActiveEvent(chat: Chat, date: Date): Promise { 68 | const event: Event = new Event(); 69 | event.chat = chat; 70 | event.active = true; 71 | event.date = date; 72 | 73 | return this.eventRepository.save(event); 74 | } 75 | 76 | /** 77 | * Finds active event for given chat and returns it 78 | * @param {Chat} chat 79 | * @returns {(Promise)} 80 | * @memberOf StorageService 81 | */ 82 | public findChatActiveEvent(chat: Chat): Promise { 83 | return this.eventRepository.findOne({where: {chat, active: true}}); 84 | } 85 | 86 | /** 87 | * Returns list of players for given event 88 | * @param {Event} event 89 | * @returns {Promise} 90 | * @memberOf StorageService 91 | */ 92 | public getPlayers(event: Event): Promise { 93 | return this.playerRepository.find({where: {event}}); 94 | } 95 | 96 | /** 97 | * Finds player by his name for given event 98 | * @param {Event} event 99 | * @param {string} name 100 | * @returns {(Promise)} 101 | * @memberOf StorageService 102 | */ 103 | public findPlayer(event: Event, name: string): Promise { 104 | return this.playerRepository.findOne({where: {event, name}}); 105 | } 106 | 107 | /** 108 | * Creates new player with given name for event 109 | * @param {Event} event 110 | * @param {string} name 111 | * @returns {Promise} 112 | * @memberOf StorageService 113 | */ 114 | public addPlayer(event: Event, name: string): Promise { 115 | const player: Player = new Player(); 116 | player.event = event; 117 | player.name = name; 118 | 119 | return this.playerRepository.save(player); 120 | } 121 | 122 | /** 123 | * Removes given player 124 | * @param {Player} player 125 | * @returns {Promise} 126 | * @memberOf StorageService 127 | */ 128 | public removePlayer(player: Player): Promise { 129 | return this.playerRepository.remove(player); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /test/integration/scenarios.spec.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import {expect} from 'chai'; 3 | 4 | import { 5 | createContextStub, 6 | createEventAddContextStub, 7 | } from '../stubs/context.stub'; 8 | import {createModuleStub} from '../stubs/actions.module.stub'; 9 | import {clearDatabase} from '../helpers/db-helper'; 10 | 11 | import {AppEmitter} from '../../src/common/event-bus.service'; 12 | import {StorageService} from '../../src/storage/storage.service'; 13 | import {Event} from '../../src/storage/models/event'; 14 | import {Player} from '../../src/storage/models/player'; 15 | 16 | describe('complex cases', () => { 17 | let appEmitter: AppEmitter; 18 | let storageService: StorageService; 19 | 20 | before(async () => { 21 | const testModule = await createModuleStub(); 22 | 23 | appEmitter = testModule.get(AppEmitter); 24 | storageService = testModule.get(StorageService); 25 | }); 26 | 27 | beforeEach(async () => { 28 | await clearDatabase(storageService); 29 | }); 30 | 31 | function addPlayer_(name, chatId = 1) { 32 | return new Promise(resolve => { 33 | appEmitter.emit( 34 | appEmitter.PLAYER_ADD, 35 | createContextStub({text: name, chatId}, resolve), 36 | ); 37 | }); 38 | } 39 | 40 | let events: Event[]; 41 | let players: Player[]; 42 | 43 | beforeEach(async () => { 44 | await new Promise(resolve => { 45 | appEmitter.emit( 46 | appEmitter.EVENT_ADD, 47 | createEventAddContextStub({}, resolve), 48 | ); 49 | }); 50 | 51 | await addPlayer_('player-1-1'); 52 | await addPlayer_('player-1-2'); 53 | 54 | events = await storageService.connection 55 | .getRepository(Event) 56 | .find({where: {active: true}}); 57 | expect(events).to.be.lengthOf(1); 58 | 59 | players = await storageService.connection 60 | .getRepository(Player) 61 | .find({where: {event: events[0]}}); 62 | 63 | expect(players).to.be.lengthOf(2); 64 | expect(players[0].name).to.equal('player-1-1'); 65 | expect(players[1].name).to.equal('player-1-2'); 66 | }); 67 | 68 | it('should create empty players set for each created event', async () => { 69 | await new Promise(resolve => { 70 | appEmitter.emit( 71 | appEmitter.EVENT_ADD, 72 | createEventAddContextStub({}, resolve), 73 | ); 74 | }); 75 | 76 | await addPlayer_('player-2-1'); 77 | 78 | events = await storageService.connection 79 | .getRepository(Event) 80 | .find({where: {active: true}}); 81 | expect(events).to.be.lengthOf(1); 82 | 83 | players = await storageService.connection 84 | .getRepository(Player) 85 | .find({where: {event: events[0]}}); 86 | 87 | expect(players).to.be.lengthOf(1); 88 | expect(players[0].name).to.equal('player-2-1'); 89 | 90 | players = await storageService.getPlayers(events[0]); 91 | 92 | expect(players).to.be.lengthOf(1); 93 | expect(players[0].name).to.equal('player-2-1'); 94 | }); 95 | 96 | it('should add players with same names to different events', async () => { 97 | await new Promise(resolve => { 98 | appEmitter.emit( 99 | appEmitter.EVENT_ADD, 100 | createEventAddContextStub({}, resolve), 101 | ); 102 | }); 103 | 104 | await addPlayer_('player-1-1'); 105 | await addPlayer_('player-1-2'); 106 | 107 | events = await storageService.connection 108 | .getRepository(Event) 109 | .find({where: {active: true}}); 110 | 111 | players = await storageService.getPlayers(events[0]); 112 | 113 | expect(players).to.be.lengthOf(2); 114 | expect(players[0].name).to.equal('player-1-1'); 115 | expect(players[1].name).to.equal('player-1-2'); 116 | }); 117 | 118 | it('should have own active event for different chats', async () => { 119 | await new Promise(resolve => { 120 | const ctx = createEventAddContextStub({chatId: 2}, resolve); 121 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 122 | }); 123 | 124 | events = await storageService.connection 125 | .getRepository(Event) 126 | .find({relations: ['chat']}); 127 | 128 | expect(events[0].chat.chatId).to.equal(1); 129 | expect(events[0].active).to.equal(true); 130 | expect(events[1].chat.chatId).to.equal(2); 131 | expect(events[1].active).to.equal(true); 132 | 133 | await addPlayer_('player-2-1', 2); 134 | await addPlayer_('player-2-2', 2); 135 | 136 | const jsonRes1: string = await new Promise(resolve => { 137 | const ctx = createContextStub({chatId: 1}, resolve); 138 | appEmitter.emit(appEmitter.EVENT_INFO, ctx); 139 | }); 140 | const data1: any = JSON.parse(jsonRes1).data; 141 | 142 | const jsonRes2: string = await new Promise(resolve => { 143 | const ctx = createContextStub({chatId: 2}, resolve); 144 | appEmitter.emit(appEmitter.EVENT_INFO, ctx); 145 | }); 146 | const data2: any = JSON.parse(jsonRes2).data; 147 | 148 | expect(data1.players).to.eql([ 149 | {index: 1, name: 'player-1-1'}, 150 | {index: 2, name: 'player-1-2'}, 151 | ]); 152 | expect(data2.players).to.eql([ 153 | {index: 1, name: 'player-2-1'}, 154 | {index: 2, name: 'player-2-2'}, 155 | ]); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /test/actions/event_add.action.spec.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import {expect} from 'chai'; 3 | 4 | import { 5 | createContextStub, 6 | createEventAddContextStub, 7 | } from '../stubs/context.stub'; 8 | import {createModuleStub} from '../stubs/actions.module.stub'; 9 | import {clearDatabase} from '../helpers/db-helper'; 10 | 11 | import {AppEmitter} from '../../src/common/event-bus.service'; 12 | import {StorageService} from '../../src/storage/storage.service'; 13 | import {Chat} from '../../src/storage/models/chat'; 14 | import {Event} from '../../src/storage/models/event'; 15 | 16 | describe('EventAddAction', () => { 17 | let appEmitter: AppEmitter; 18 | let storageService: StorageService; 19 | 20 | async function assertNoEvents_() { 21 | const eventsAmount = await storageService.connection 22 | .getRepository(Event) 23 | .count(); 24 | 25 | expect(eventsAmount).to.equal(0); 26 | } 27 | 28 | before(async () => { 29 | const testModule = await createModuleStub(); 30 | 31 | appEmitter = testModule.get(AppEmitter); 32 | storageService = testModule.get(StorageService); 33 | }); 34 | 35 | beforeEach(async () => { 36 | await clearDatabase(storageService); 37 | }); 38 | 39 | describe('handle event', () => { 40 | it('should create chat if it does not exist yet', async () => { 41 | await assertNoEvents_(); 42 | 43 | await new Promise(resolve => { 44 | const ctx = createEventAddContextStub({}, resolve); 45 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 46 | }); 47 | 48 | const chatCountAfter: number = await storageService.connection 49 | .getRepository(Chat) 50 | .count(); 51 | expect(chatCountAfter).to.equal(1); 52 | }); 53 | 54 | it('should create new event and mark it as active', async () => { 55 | await assertNoEvents_(); 56 | 57 | await new Promise(resolve => { 58 | const ctx = createEventAddContextStub({}, resolve); 59 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 60 | }); 61 | 62 | const events: Event[] = await storageService.connection 63 | .getRepository(Event) 64 | .find({}); 65 | 66 | expect(events).to.be.lengthOf(1); 67 | expect(events[0].active).to.equal(true); 68 | }); 69 | 70 | it('should return information about created event', async () => { 71 | const jsonRes: string = await new Promise(resolve => { 72 | const ctx = createEventAddContextStub({}, resolve); 73 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 74 | }); 75 | 76 | const {data} = JSON.parse(jsonRes); 77 | expect(data.date).to.match(/^\d{2}-\d{2}-\d{4}\s\d{2}:\d{2}$/); 78 | }); 79 | 80 | describe('it should not allow to create event', () => { 81 | it('without date', async () => { 82 | const jsonRes: string = await new Promise(resolve => { 83 | const ctx = createEventAddContextStub({ 84 | text: '/event_add', 85 | }, resolve); 86 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 87 | }); 88 | 89 | const {params} = JSON.parse(jsonRes); 90 | expect(params.status).to.equal('invalid_date'); 91 | await assertNoEvents_(); 92 | }); 93 | 94 | it('with date given in invalid format', async () => { 95 | const jsonRes: string = await new Promise(resolve => { 96 | const ctx = createEventAddContextStub({ 97 | text: '/event_add aa-2a-b', 98 | }, resolve); 99 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 100 | }); 101 | 102 | const {params} = JSON.parse(jsonRes); 103 | expect(params.status).to.equal('invalid_date'); 104 | await assertNoEvents_(); 105 | }); 106 | 107 | it('with date in past', async () => { 108 | const jsonRes: string = await new Promise(resolve => { 109 | const ctx = createEventAddContextStub({ 110 | text: '/event_add 01-01-1970 00:01', 111 | }, resolve); 112 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 113 | }); 114 | 115 | const {params} = JSON.parse(jsonRes); 116 | expect(params.status).to.equal('invalid_date_past'); 117 | await assertNoEvents_(); 118 | }); 119 | }); 120 | 121 | describe('existed events', () => { 122 | let events: Event[]; 123 | 124 | beforeEach(async () => { 125 | await assertNoEvents_(); 126 | 127 | await new Promise(resolve => { 128 | const ctx = createEventAddContextStub({}, resolve); 129 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 130 | }); 131 | 132 | events = await storageService.connection 133 | .getRepository(Event) 134 | .find({}); 135 | expect(events.length).to.equal(1); 136 | expect(events[0].active).to.equal(true); 137 | }); 138 | 139 | it('should make all existed events inactive', async () => { 140 | await new Promise(resolve => { 141 | const ctx = createEventAddContextStub({}, resolve); 142 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 143 | }); 144 | 145 | events = await storageService.connection 146 | .getRepository(Event) 147 | .find({}); 148 | expect(events.length).to.equal(2); 149 | expect(events[0].active).to.equal(false); 150 | expect(events[1].active).to.equal(true); 151 | }); 152 | 153 | it('should not deactivate existed event if current event date is invalid', async () => { 154 | await new Promise(resolve => { 155 | const ctx = createEventAddContextStub({ 156 | text: '/event_add', 157 | }, resolve); 158 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 159 | }); 160 | 161 | events = await storageService.connection 162 | .getRepository(Event) 163 | .find({}); 164 | expect(events.length).to.equal(1); 165 | expect(events[0].active).to.equal(true); 166 | }); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /test/actions/player_remove.action.spec.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import {expect} from 'chai'; 3 | 4 | import { 5 | createContextStub, 6 | createEventAddContextStub, 7 | } from '../stubs/context.stub'; 8 | import {createModuleStub} from '../stubs/actions.module.stub'; 9 | import {clearDatabase} from '../helpers/db-helper'; 10 | 11 | import {AppEmitter} from '../../src/common/event-bus.service'; 12 | import {StorageService} from '../../src/storage/storage.service'; 13 | import * as statuses from '../../src/actions/statuses'; 14 | import {Chat} from '../../src/storage/models/chat'; 15 | import {Player} from '../../src/storage/models/player'; 16 | import {IParams} from '../../src/common/template.service'; 17 | 18 | describe('PlayerRemoveAction', () => { 19 | let appEmitter: AppEmitter; 20 | let storageService: StorageService; 21 | 22 | before(async () => { 23 | const testModule = await createModuleStub(); 24 | 25 | appEmitter = testModule.get(AppEmitter); 26 | storageService = testModule.get(StorageService); 27 | }); 28 | 29 | beforeEach(async () => { 30 | await clearDatabase(storageService); 31 | }); 32 | 33 | describe('handle event', () => { 34 | it('should create chat if it does not exist yet', async () => { 35 | const chatCountBefore: number = await storageService.connection 36 | .getRepository(Chat) 37 | .count(); 38 | expect(chatCountBefore).to.equal(0); 39 | 40 | await new Promise(resolve => { 41 | const ctx = createContextStub({lang: 'en', chatId: 1}, resolve); 42 | appEmitter.emit(appEmitter.PLAYER_REMOVE, ctx); 43 | }); 44 | 45 | const chatCountAfter: number = await storageService.connection 46 | .getRepository(Chat) 47 | .count(); 48 | expect(chatCountAfter).to.equal(1); 49 | }); 50 | 51 | it('should return no_event response if active event was not found', async () => { 52 | const jsonRes: string = await new Promise(resolve => { 53 | const ctx = createContextStub({lang: 'en', chatId: 1}, resolve); 54 | appEmitter.emit(appEmitter.PLAYER_REMOVE, ctx); 55 | }); 56 | 57 | const {params}: {params: IParams} = JSON.parse(jsonRes); 58 | expect(params.status).to.equal(statuses.STATUS_NO_EVENT); 59 | }); 60 | 61 | describe('active event exists', () => { 62 | beforeEach(async () => { 63 | await new Promise(resolve => { 64 | const ctx = createEventAddContextStub( 65 | {lang: 'en', chatId: 1}, 66 | resolve, 67 | ); 68 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 69 | }); 70 | }); 71 | 72 | it('should return no_player response if player was not found by given name', async () => { 73 | const jsonRes: string = await new Promise(resolve => { 74 | const ctx = createContextStub( 75 | {text: 'John Smith'}, 76 | resolve, 77 | ); 78 | appEmitter.emit(appEmitter.PLAYER_REMOVE, ctx); 79 | }); 80 | 81 | const {params}: {params: IParams} = JSON.parse(jsonRes); 82 | expect(params.status).to.equal(statuses.STATUS_NO_PLAYER); 83 | }); 84 | 85 | describe('player exist', () => { 86 | async function assertExistedPlayer_() { 87 | const players: Player[] = await storageService.connection 88 | .getRepository(Player) 89 | .find(); 90 | expect(players[0].name).to.equal('John Smith'); 91 | } 92 | 93 | async function assertNotExistedPlayer_() { 94 | const playersCount: number = await storageService.connection 95 | .getRepository(Player) 96 | .count(); 97 | 98 | expect(playersCount).to.equal(0); 99 | } 100 | 101 | beforeEach(async () => { 102 | await new Promise(resolve => { 103 | const ctx = createContextStub( 104 | {text: 'John Smith'}, 105 | resolve, 106 | ); 107 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 108 | }); 109 | }); 110 | 111 | it('should remove player with given name', async () => { 112 | await assertExistedPlayer_(); 113 | 114 | await new Promise(resolve => { 115 | const ctx = createContextStub( 116 | {text: 'John Smith'}, 117 | resolve, 118 | ); 119 | appEmitter.emit(appEmitter.PLAYER_REMOVE, ctx); 120 | }); 121 | 122 | await assertNotExistedPlayer_(); 123 | }); 124 | 125 | it('should remove message owner as player if name was not set', async () => { 126 | await assertExistedPlayer_(); 127 | 128 | await new Promise(resolve => { 129 | const ctx = createContextStub( 130 | {firstName: 'John', lastName: 'Smith'}, 131 | resolve, 132 | ); 133 | appEmitter.emit(appEmitter.PLAYER_REMOVE, ctx); 134 | }); 135 | 136 | await assertNotExistedPlayer_(); 137 | }); 138 | 139 | it('should return success result', async () => { 140 | const jsonRes: string = await new Promise(resolve => { 141 | const ctx = createContextStub( 142 | {text: 'John Smith'}, 143 | resolve, 144 | ); 145 | appEmitter.emit(appEmitter.PLAYER_REMOVE, ctx); 146 | }); 147 | 148 | const {params}: {params: IParams} = JSON.parse(jsonRes); 149 | expect(params.status).to.equal(statuses.STATUS_SUCCESS); 150 | }); 151 | 152 | it('should inlude name of removed player into result', async () => { 153 | const jsonRes: string = await new Promise(resolve => { 154 | const ctx = createContextStub( 155 | {text: 'John Smith'}, 156 | resolve, 157 | ); 158 | appEmitter.emit(appEmitter.PLAYER_REMOVE, ctx); 159 | }); 160 | 161 | const {data} = JSON.parse(jsonRes); 162 | expect(data.name).to.equal('John Smith'); 163 | }); 164 | 165 | it('should include list of players into result', async () => { 166 | await new Promise(resolve => { 167 | const ctx = createContextStub( 168 | {text: 'Jack Wayne'}, 169 | resolve, 170 | ); 171 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 172 | }); 173 | 174 | const jsonRes: string = await new Promise(resolve => { 175 | const ctx = createContextStub( 176 | {text: 'John Smith'}, 177 | resolve, 178 | ); 179 | appEmitter.emit(appEmitter.PLAYER_REMOVE, ctx); 180 | }); 181 | 182 | const {data} = JSON.parse(jsonRes); 183 | expect(data.total).to.equal(1); 184 | expect(data.players[0].index).to.equal(1); 185 | expect(data.players[0].name).to.equal('Jack Wayne'); 186 | }); 187 | }); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /test/actions/player_add.action.spec.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import {expect} from 'chai'; 3 | 4 | import { 5 | createContextStub, 6 | createEventAddContextStub, 7 | } from '../stubs/context.stub'; 8 | import {createModuleStub} from '../stubs/actions.module.stub'; 9 | import {clearDatabase} from '../helpers/db-helper'; 10 | 11 | import {AppEmitter} from '../../src/common/event-bus.service'; 12 | import {StorageService} from '../../src/storage/storage.service'; 13 | import * as statuses from '../../src/actions/statuses'; 14 | import {Chat} from '../../src/storage/models/chat'; 15 | import {Player} from '../../src/storage/models/player'; 16 | import {IParams} from '../../src/common/template.service'; 17 | 18 | describe('PlayerAddAction', () => { 19 | let appEmitter: AppEmitter; 20 | let storageService: StorageService; 21 | 22 | before(async () => { 23 | const testModule = await createModuleStub(); 24 | 25 | appEmitter = testModule.get(AppEmitter); 26 | storageService = testModule.get(StorageService); 27 | }); 28 | 29 | beforeEach(async () => { 30 | await clearDatabase(storageService); 31 | }); 32 | 33 | describe('handle event', () => { 34 | it('should create chat if it does not exist yet', async () => { 35 | const chatCountBefore: number = await storageService.connection 36 | .getRepository(Chat) 37 | .count(); 38 | expect(chatCountBefore).to.equal(0); 39 | 40 | await new Promise(resolve => { 41 | const ctx = createContextStub({}, resolve); 42 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 43 | }); 44 | 45 | const chatCountAfter: number = await storageService.connection 46 | .getRepository(Chat) 47 | .count(); 48 | expect(chatCountAfter).to.equal(1); 49 | }); 50 | 51 | it('should return no_event response if active event was not found', async () => { 52 | const jsonRes: string = await new Promise(resolve => { 53 | const ctx = createContextStub({}, resolve); 54 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 55 | }); 56 | 57 | const {params}: {params: IParams} = JSON.parse(jsonRes); 58 | expect(params.status).to.equal(statuses.STATUS_NO_EVENT); 59 | }); 60 | 61 | describe('active event exists', () => { 62 | beforeEach(async () => { 63 | await new Promise(resolve => { 64 | const ctx = createEventAddContextStub({}, resolve); 65 | appEmitter.emit(appEmitter.EVENT_ADD, ctx); 66 | }); 67 | }); 68 | 69 | it('should add player with given name', async () => { 70 | await new Promise(resolve => { 71 | const ctx = createContextStub( 72 | {text: 'John Smith'}, 73 | resolve, 74 | ); 75 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 76 | }); 77 | 78 | const players: Player[] = await storageService.connection 79 | .getRepository(Player) 80 | .find(); 81 | expect(players[0].name).to.equal('John Smith'); 82 | }); 83 | 84 | describe('should add message owner as player ', () => { 85 | it('if name was not set', async () => { 86 | await new Promise(resolve => { 87 | const ctx = createContextStub( 88 | {firstName: 'John', lastName: 'Smith'}, 89 | resolve, 90 | ); 91 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 92 | }); 93 | 94 | const players: Player[] = await storageService.connection 95 | .getRepository(Player) 96 | .find(); 97 | expect(players[0].name).to.equal('John Smith'); 98 | }); 99 | 100 | it('and use his first name only if last name was not set', async () => { 101 | await new Promise(resolve => { 102 | const ctx = createContextStub( 103 | {firstName: 'John'}, 104 | resolve, 105 | ); 106 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 107 | }); 108 | 109 | const players: Player[] = await storageService.connection 110 | .getRepository(Player) 111 | .find(); 112 | expect(players[0].name).to.equal('John'); 113 | }); 114 | 115 | it('and use his last name only if first name was not set', async () => { 116 | await new Promise(resolve => { 117 | const ctx = createContextStub( 118 | {lastName: 'Smith'}, 119 | resolve, 120 | ); 121 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 122 | }); 123 | 124 | const players: Player[] = await storageService.connection 125 | .getRepository(Player) 126 | .find(); 127 | expect(players[0].name).to.equal('Smith'); 128 | }); 129 | }); 130 | 131 | it('should return success result', async () => { 132 | const jsonRes: string = await new Promise(resolve => { 133 | const ctx = createContextStub( 134 | {firstName: 'John', lastName: 'Smith'}, 135 | resolve, 136 | ); 137 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 138 | }); 139 | 140 | const {params}: {params: IParams} = JSON.parse(jsonRes); 141 | expect(params.status).to.equal(statuses.STATUS_SUCCESS); 142 | }); 143 | 144 | it('should include name of added player into result', async () => { 145 | const jsonRes: string = await new Promise(resolve => { 146 | const ctx = createContextStub( 147 | {firstName: 'John', lastName: 'Smith'}, 148 | resolve, 149 | ); 150 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 151 | }); 152 | 153 | const {data} = JSON.parse(jsonRes); 154 | expect(data.name).to.equal('John Smith'); 155 | }); 156 | 157 | it('should include list of players into result', async () => { 158 | const jsonRes: string = await new Promise(resolve => { 159 | const ctx = createContextStub( 160 | {firstName: 'John', lastName: 'Smith'}, 161 | resolve, 162 | ); 163 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 164 | }); 165 | 166 | const {data} = JSON.parse(jsonRes); 167 | expect(data.total).to.equal(1); 168 | expect(data.players[0].index).to.equal(1); 169 | expect(data.players[0].name).to.equal('John Smith'); 170 | }); 171 | 172 | it('should return already added response if player with given name has already been added', async () => { 173 | await new Promise(resolve => { 174 | const ctx = createContextStub( 175 | {firstName: 'John', lastName: 'Smith'}, 176 | resolve, 177 | ); 178 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 179 | }); 180 | 181 | const jsonRes: string = await new Promise(resolve => { 182 | const ctx = createContextStub( 183 | {firstName: 'John', lastName: 'Smith'}, 184 | resolve, 185 | ); 186 | appEmitter.emit(appEmitter.PLAYER_ADD, ctx); 187 | }); 188 | 189 | const {params}: {params: IParams} = JSON.parse(jsonRes); 190 | expect(params.status).to.equal(statuses.STATUS_ALREADY_ADDED); 191 | }); 192 | }); 193 | }); 194 | }); 195 | --------------------------------------------------------------------------------