1. Introdução
Olá pessoal. Neste artigo, descreverei meu chatbot para o serviço de mensagens de telegrama e a rede social VK usando o NodeJS.
Neste ponto, muitos leitores devem começar algo como: "Quanto tempo!" ou "O que, de novo ?!"
Sim, publicações semelhantes já estavam em um habr inclusive. Mas, no entanto, acredito que o artigo será útil. Resumidamente, o que a implementação técnica do bot representa:
- Como estrutura para o aplicativo, a estrutura NestJS está ganhando popularidade.
- A biblioteca de telegraf para interagir com a API do Telegram.
- A biblioteca node-vk-bot-api para interagir com a API VK.
- Biblioteca Typeorm para organizar a camada de armazenamento de dados.
- Testes usando mocha e a biblioteca chai assert .
- IC usando o Travis CI para teste e as Ações do GitHub para implantar imagens do docker.
Como um trabalho paralelo, vamos tentar fazer nossos amigos bot do Viber, tornando-o tão universal para uso em vários serviços de mensagens.
Para quem quer saber o que aconteceu, seja bem-vindo ao gato.
Declaração do problema
Como atividade física útil, prefiro futebol. Em um nível amador, é claro. Como membro de vários canais e bate-papos em vk, viber e telegrama, muitas vezes é necessário ver a seguinte imagem:

O que vemos aqui:
- "P" se adicionou.
- Depois dele, mais 3 pessoas foram adicionadas, entre as quais havia um certo "C".
- "P" adicionou seu camarada chamado "Dimon".
- Depois disso, "P" não ficou com preguiça e calculou que, no momento, já existem até 5 pessoas.
- No entanto, essas informações não foram relevantes por muito tempo, pois também decidi executar e me adicionar.
Em casos especialmente negligenciados, as pessoas podem colocar um “+” e cancelar sua participação, convidar amigos que não estão no bate-papo ou se inscrever em outros participantes do bate-papo e realizar outras atividades. No final, isso leva ao fato de que, quando todos acabam jogando, acontece que:
- Vasya definiu +1, significando não apenas ele mesmo, mas também seu amigo Gosh.
- Kolya escreveu que ele viria, mas o organizador Spiridon considerou com seus olhos apenas as vantagens.
Como resultado, temos possivelmente um número desigual de equipes, “Gosh extra” e inesperadamente apareceram Kolya.
Para evitar tais colisões, escrevi um bot simples que ajuda a exibir visualmente a composição dos participantes do próximo jogo e é conveniente adicionar (s) / excluir (s).
Portanto, na minha implementação básica, na minha opinião, o bot deve ser capaz de:
- Ser incorporado em qualquer número de bate-papos. Armazene seu estado para cada bate-papo separadamente.
- Usando o comando / event_add, crie um novo evento ativo com determinadas informações e uma lista vazia de jogadores. O evento ativo anterior deve ficar inativo.
- O comando / event_remove deve cancelar o evento ativo no momento.
- O comando / add deve adicionar um novo membro ao evento ativo no momento. Além disso, se o comando for chamado sem nenhum texto adicional, então quem digitou o comando deve ser adicionado. Caso contrário, é adicionada uma pessoa cujo nome é especificado quando o comando é chamado.
- O comando / remove remove uma pessoa da lista de participantes de acordo com as regras descritas para o comando / add .
- O comando / info permite que você veja quem já se inscreveu no jogo.
- É altamente desejável que o bot responda no mesmo idioma que o player configurado (ou em inglês por padrão).
Para ver rapidamente o que aconteceu no final, você pode ir imediatamente para a última seção "Resultado e conclusões". Bem, se você estiver interessado nos detalhes da implementação, essas informações serão descritas em detalhes suficientes abaixo.
Design e desenvolvimento
Ao projetar o aplicativo, o seguinte esquema apareceu na minha cabeça:

Aqui, a idéia principal era trazer todas as especificidades do trabalho com vários sistemas de mensagens para os adaptadores apropriados e encapsular a lógica da interação com as bibliotecas implementando as APIs correspondentes, processando e trazendo dados para um único formulário.
Modelos de interface e mensagem
Para mensagens, foi possível projetar a seguinte interface IMessage , que é satisfeita pelas classes que armazenam dados de mensagens recebidas para vários sistemas e métodos para trabalhar com esses dados:
export interface IMessage { chatId: number; lang: string; text: string; fullText: string; command: string; name: string; getReplyStatus: () => string; getReplyData: () => any; setStatus: (status: string) => IMessage; withData: (data: any) => IMessage; answer: (args: any) => string | void; }
A classe base BaseMessage que implementa essa interface tem o seguinte formato:
Mensagem base import {IMessage} from '../message/i-message'; export class BaseMessage implements IMessage { public chatId: number; public lang: string; public text: string; public fullText: string; public command: string; protected firstName: string; protected lastName: string; protected replyStatus: string; protected replyData: any; get name(): string { const firstName: string = this.firstName || ''; const lastName: string = this.lastName || ''; return `${firstName} ${lastName}`.trim(); } public getReplyStatus(): string { return this.replyStatus; } public getReplyData(): any { return this.replyData; } public setStatus(status: string): IMessage { this.replyStatus = status; return this; } public withData(data: any): IMessage { this.replyData = data; return this; } public answer(args: any): string | void { throw new Error('not implemented'); } } }
Classe de mensagem para o Telegram:
TelegramMessage import {BaseMessage} from '../message/base.message'; import {IMessage} from '../message/i-message'; export class TelegramMessage extends BaseMessage implements IMessage { private ctx: any; constructor(ctx) { super(); this.ctx = ctx; const {message} = this.ctx.update; this.chatId = message.chat.id; this.fullText = message.text; this.command = this.ctx.command; this.text = this.fullText.replace(`/${this.command}`, ''); this.lang = message.from.language_code; this.firstName = message.from.first_name; this.lastName = message.from.last_name; } public answer(args: any): string | void { return this.ctx.replyWithHTML(args); } }
e para VK:
VKMessage import {BaseMessage} from '../message/base.message'; import {IMessage} from '../message/i-message'; export class VKMessage extends BaseMessage implements IMessage { private ctx: any; constructor(ctx) { super(); this.ctx = ctx; const {message} = this.ctx; this.chatId = this.getChatId(this.ctx); this.fullText = message.text; this.command = this.ctx.command; this.text = this.fullText.replace(`/${this.command}`, ''); this.lang = 'ru'; this.firstName = message.from.first_name; this.lastName = message.from.last_name; } public answer(args: any) { const answer: string = `${args}`.replace(/<\/?(strong|i)>/gm, ''); this.ctx.reply(answer); } private getChatId({message, bot}): number { const peerId: number = +`${message.peer_id}`.replace(/[0-9]0+/, ''); const groupId: number = bot.settings.group_id; return peerId + groupId; } }
O que você deve prestar atenção:
chatId é o identificador exclusivo para o bate-papo. Para o Telegram, ele vem explicitamente na estrutura de cada mensagem. No entanto, no caso de VK, não existe esse identificador explicitamente. Como ser Para responder a essa pergunta, você precisa entender como o bot funciona no VK. Nesse sistema, um bot na correspondência de grupo atua em nome da comunidade para a qual ele inicia. I.e. cada mensagem bot contém um identificador de comunidade group_id . Além disso, peer_id aparece na mensagem (mais detalhes podem ser lidos aqui ) como o identificador da conversa em grupo no nosso caso. Com base nesses dois identificadores, você pode criar seu identificador de bate-papo, por exemplo, da seguinte maneira:
private getChatId({message, bot}): number { const peerId: number = +(`${message.peer_id}`.replace(/[0-9]0+/, '')); const groupId: number = bot.settings.group_id; return peerId + groupId; }
Esse método certamente não é perfeito, mas é aplicável à tarefa atual.
fullText e texto . Como o nome das variáveis indica , fullText contém o texto completo da mensagem, enquanto o texto contém apenas a parte que vem após o nome do comando.
Isso é conveniente porque permite que você use o campo de texto pronto para analisar as informações auxiliares contidas nele, como a data do evento ou o nome do player.
- Diferentemente do Telegram, as mensagens VK não suportam um conjunto de tags para destacar texto como
<b>
ou <i>
; portanto, ao responder, essas tags devem ser excluídas usando expressões regulares.
Adaptadores para receber e enviar mensagens
Depois de criar a interface e as estruturas de dados para mensagens Telegram e VK, chegou a hora de implementar serviços para o processamento de eventos recebidos.

Esses serviços devem inicializar módulos de terceiros para interagir com as APIs Telegram e VK e iniciar mecanismos de pesquisa longa, além de ter uma conexão entre comandos recebidos e eventos internos que ocorrem no sistema ao receber dados de serviços de mensagens.
TelegramService import Telegraf from 'telegraf'; import {Injectable} from '@nestjs/common'; import * as SocksAgent from 'socks5-https-client/lib/Agent'; import {ConfigService} from '../common/config.service'; import {AppEmitter} from '../common/event-bus.service'; import {TelegramMessage} from './telegram.message'; @Injectable() export class TelegramService { private bot: Telegraf<any>; constructor(config: ConfigService, appEmitter: AppEmitter) { const botToken: string = config.get('TELEGRAM_BOT_TOKEN'); this.bot = config.get('TELEGRAM_USE_PROXY') ? new Telegraf(botToken, { telegram: {agent: this.getProxy(config)}, }) : new Telegraf(botToken); this.getCommandEventMapping(appEmitter).forEach(([command, event]) => { this.bot.command(command, ctx => { ctx.command = command; appEmitter.emit(event, new TelegramMessage(ctx)); }); }); } public launch(): void { this.bot.launch(); } private getProxy(config: ConfigService): SocksAgent { return new SocksAgent({ socksHost: config.get('TELEGRAM_PROXY_HOST'), socksPort: config.get('TELEGRAM_PROXY_PORT'), socksUsername: config.get('TELEGRAM_PROXY_LOGIN'), socksPassword: config.get('TELEGRAM_PROXY_PASSWORD'), }); } private getCommandEventMapping( appEmitter: AppEmitter, ): Array<[string, string]> { return [ ['event_add', appEmitter.EVENT_ADD], ['event_remove', appEmitter.EVENT_REMOVE], ['info', appEmitter.EVENT_INFO], ['add', appEmitter.PLAYER_ADD], ['remove', appEmitter.PLAYER_REMOVE], ]; } }
VKService import * as VkBot from 'node-vk-bot-api'; import {Injectable} from '@nestjs/common'; import {ConfigService} from '../common/config.service'; import {AppEmitter} from '../common/event-bus.service'; import {VKMessage} from './vk.message'; @Injectable() export class VKService { private bot: VkBot<any>; constructor(config: ConfigService, appEmitter: AppEmitter) { const botToken: string = config.get('VK_TOKEN'); this.bot = new VkBot(botToken); this.getCommandEventMapping(appEmitter).forEach(([command, event]) => { this.bot.command(`/${command}`, async ctx => { const [from] = await this.bot.execute('users.get', { user_ids: ctx.message.from_id, }); ctx.message.from = from; ctx.command = command; appEmitter.emit(event, new VKMessage(ctx)); }); }); } public launch(): void { this.bot.startPolling(); } private getCommandEventMapping( appEmitter: AppEmitter, ): Array<[string, string]> { return [ ['event_add', appEmitter.EVENT_ADD], ['event_remove', appEmitter.EVENT_REMOVE], ['info', appEmitter.EVENT_INFO], ['add', appEmitter.PLAYER_ADD], ['remove', appEmitter.PLAYER_REMOVE], ]; } }
O que você deve prestar atenção:
- Devido a alguns problemas de acesso ao serviço Telegram (oi Roskomnadzor), era necessário usar opcionalmente um proxy que fornecesse o pacote socks5-https-client .
- Ao processar um evento, um campo é adicionado ao contexto com o valor do comando com o texto do qual a mensagem foi recebida.
- Para o VK, você deve baixar separadamente os dados do usuário que enviou a mensagem usando uma chamada de API separada:
const [from] = await this.bot.execute('users.get', { user_ids: ctx.message.from_id, });
Modelo de dados
Para implementar a funcionalidade declarada do bot, três modelos foram suficientes:
Chat
-papo - bate-papo ou conversa.Event
- um evento para um bate-papo agendado para uma data e hora específicas.Player
- participante ou jogador participando do evento.

As implementações de modelos usando a biblioteca typeorm são:
Bate-papo import { Entity, PrimaryGeneratedColumn, Column, OneToMany, JoinColumn, Index, } from 'typeorm'; import {Event} from './event'; @Entity() export class Chat { @PrimaryGeneratedColumn() id: number; @Index({unique: true}) @Column() chatId: number; @OneToMany(type => Event, event => event.chat) @JoinColumn() events: Event[]; }
Evento import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToOne, JoinColumn, } from 'typeorm'; import {Chat} from './chat'; import {Player} from './player'; @Entity() export class Event { @PrimaryGeneratedColumn() id: number; @Column() date: Date; @Column() active: boolean; @ManyToOne(type => Chat, chat => chat.events) chat: Chat; @OneToMany(type => Player, player => player.event) @JoinColumn() players: Player[]; }
Jogador import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, } from 'typeorm'; import {Event} from './event'; @Entity() export class Player { @PrimaryGeneratedColumn() id: number; @Column() name: string; @ManyToOne(type => Event, event => event.players) @JoinColumn() event: Event; }
Aqui é necessário discutir novamente o mecanismo do bot para várias equipes em termos de manipulação dos modelos de bate-papo , evento e jogador .
- Após o recebimento de qualquer comando, a existência de um bate-papo no banco de dados é verificada com o
chatId
. Se não houver registro correspondente, ele será criado. - Para simplificar a lógica, cada bate-papo pode ter apenas um evento ativo ( Evento ). I.e. quando o comando
/event_add
é /event_add
, um novo evento ativo com a data especificada é criado no sistema
O evento ativo atual (se existir) se torna inativo. /event_remove
localiza o evento ativo no bate-papo atual e o desativa./info
localiza um evento ativo no chat atual, exibe informações sobre esse evento e uma lista de jogadores ( Player )./add
e /remove
trabalho com o evento ativo adicionando e removendo jogadores dele.
O serviço correspondente para trabalhar com dados é o seguinte:
StorageService import {Injectable} from '@nestjs/common'; import {InjectConnection} from '@nestjs/typeorm'; import {Connection, Repository, UpdateResult} from 'typeorm'; import {Chat} from './models/chat'; import {Event} from './models/event'; import {Player} from './models/player'; @Injectable() export class StorageService { private chatRepository: Repository<Chat>; private eventRepository: Repository<Event>; private playerRepository: Repository<Player>; constructor(@InjectConnection() private readonly dbConnection: Connection) { this.chatRepository = this.dbConnection.getRepository(Chat); this.eventRepository = this.dbConnection.getRepository(Event); this.playerRepository = this.dbConnection.getRepository(Player); } public get connection() { return this.dbConnection; } public async ensureChat(chatId: number): Promise<Chat> { let chat: Chat = await this.chatRepository.findOne({chatId}); if (chat) { return chat; } chat = new Chat(); chat.chatId = chatId; return this.chatRepository.save(chat); } public markChatEventsInactive(chat: Chat): Promise<UpdateResult> { return this.dbConnection .createQueryBuilder() .update(Event) .set({active: false}) .where({chat}) .execute(); } public appendChatActiveEvent(chat: Chat, date: Date): Promise<Event> { const event: Event = new Event(); event.chat = chat; event.active = true; event.date = date; return this.eventRepository.save(event); } public findChatActiveEvent(chat: Chat): Promise<Event | null> { return this.eventRepository.findOne({where: {chat, active: true}}); } public getPlayers(event: Event): Promise<Player[]> { return this.playerRepository.find({where: {event}}); } public findPlayer(event: Event, name: string): Promise<Player | null> { return this.playerRepository.findOne({where: {event, name}}); } public addPlayer(event: Event, name: string): Promise<Player> { const player: Player = new Player(); player.event = event; player.name = name; return this.playerRepository.save(player); } public removePlayer(player: Player): Promise<Player> { return this.playerRepository.remove(player); } }
Resposta do modelo
Além do serviço StorageService descrito para trabalhar com dados, havia também a necessidade de implementar um serviço TemplateService dedicado cujas tarefas no momento são:
- Baixe e compile modelos de guidão para opções de resposta para vários comandos em diferentes idiomas.
- Selecionando um modelo adequado, dependendo do evento atual, status da resposta e idioma do usuário.
- Preenchendo o modelo com dados obtidos como resultado do comando.
TemplateService import * as path from 'path'; import * as fs from 'fs'; import * as handlebars from 'handlebars'; import {readDirDeepSync} from 'read-dir-deep'; import {Injectable, Logger} from '@nestjs/common'; export interface IParams { action: string; status: string; lang?: string; } @Injectable() export class TemplateService { private readonly DEFAULT_LANG: string = 'en'; private readonly TEMPLATE_PATH: string = 'templates'; private logger: Logger; private templatesMap: Map<string, (d: any) => string>; constructor(logger: Logger) { this.logger = logger; this.load(); } public apply(params: IParams, data: any): string { this.logger.log( `apply template: ${params.action} ${params.status} ${params.lang}`, ); let template = this.getTemplate(params); if (!template) { params.lang = this.DEFAULT_LANG; template = this.getTemplate(params); } if (!template) { throw new Error('template-not-found'); } return template(data); } private getTemplate(params: IParams): (data: any) => string { const {lang, action, status} = params; return this.templatesMap.get(this.getTemplateKey(lang, action, status)); } private load() { const templatesDir: string = path.join( process.cwd(), this.TEMPLATE_PATH, ); const templateFileNames: string[] = readDirDeepSync(templatesDir); this.templatesMap = templateFileNames.reduce((acc, fileName) => { const template = fs.readFileSync(fileName, {encoding: 'utf-8'}); const [, lang, action, status] = fileName .replace(/\.hbs$/, '') .split('/'); return acc.set( this.getTemplateKey(lang, action, status), handlebars.compile(template), ); }, new Map()); } private getTemplateKey( lang: string, action: string, status: string, ): string { return `${lang}-${action}-${status}`; } }
O que você deve prestar atenção:
Os arquivos de modelo estão em um diretório ./templates
separado na raiz do projeto e são estruturados por idioma, ação (comando) e status de resposta.
- templates - en - event_add - invalid_date.hbs - success.hbs - event_info - ru
Quando o aplicativo é inicializado, todos os modelos de resposta são carregados na memória e preenchem um Mapa com chaves que identificam exclusivamente o modelo:
private getTemplateKey(lang: string, action: string, status: string): string { return `${lang}-${action}-${status}`; }
Atualmente, o sistema possui 2 conjuntos de modelos: para russo e inglês.
O fallback é fornecido no idioma padrão (inglês) na ausência do modelo necessário para o idioma do evento atual.
Os próprios modelos de guidão , por exemplo:
Player <strong>{{name}}</strong> will take part in the game List of players: {{#each players}} {{index}}: <i>{{name}}</i> {{/each}} Total: <strong>{{total}}</strong>
contêm espaços reservados tradicionais e um conjunto de tags válidas para formatar respostas no Telegram.
Serviços de processamento de comandos (ações)
Agora que os serviços básicos de suporte estão descritos, é hora de implementar a lógica de negócios do bot. Esquematicamente, a conexão entre os módulos é apresentada aqui:

As tarefas da classe base BaseAction incluem:
- Inscreva-se em um evento específico do AppEmitter .
- A funcionalidade geral do processamento de eventos, a saber:
- procurando por um existente ou criando um novo bate-papo no contexto em que a equipe será processada.
- Uma chamada para o método do modelo
doAction
implementação é individual para cada classe da BaseAction herdada. - Aplicando modelos à resposta
doAction
resultante usando o TemplateService .
Ação base import {Injectable, Logger} from '@nestjs/common'; import {IMessage} from '../message/i-message'; import {ConfigService} from '../common/config.service'; import {AppEmitter} from '../common/event-bus.service'; import {TemplateService} from '../common/template.service'; import {StorageService} from '../storage/storage.service'; import {Chat} from '../storage/models/chat'; @Injectable() export class BaseAction { protected appEmitter: AppEmitter; protected config: ConfigService; protected logger: Logger; protected templateService: TemplateService; protected storageService: StorageService; protected event: string; constructor( config: ConfigService, appEmitter: AppEmitter, logger: Logger, templateService: TemplateService, storageService: StorageService, ) { this.config = config; this.logger = logger; this.appEmitter = appEmitter; this.templateService = templateService; this.storageService = storageService; this.setEvent(); this.logger.log(`subscribe on "${this.event}" event`); this.appEmitter.on(this.event, this.handleEvent.bind(this)); } protected setEvent(): void { throw new Error('not implemented'); } protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> { throw new Error('not implemented'); } private async handleEvent(message: IMessage) { try { this.logger.log(`"${this.event}" event received`); const chatId: number = message.chatId; const chat: Chat = await this.storageService.ensureChat(chatId); message = await this.doAction(chat, message); message.answer( this.templateService.apply( { action: this.event, status: message.getReplyStatus(), lang: message.lang, }, message.getReplyData(), ), ); } catch (error) { this.logger.error(error); message.answer(error.message); } } }
A tarefa das classes filho BaseAction é executar o método doAction
da entrada:
- Bate-papo criado ou definido na classe base
- Um objeto em conformidade com o protocolo IMessage .
Como resultado da execução desse método, o IMessage também é retornado, mas com o status estabelecido para a escolha do modelo certo e dos dados que participarão do modelo de resposta.
EventAddAction import {Injectable} from '@nestjs/common'; import * as statuses from './statuses'; import {parseEventDate, formatEventDate} from '../common/utils'; import {BaseAction} from './base.action'; import {Chat} from '../storage/models/chat'; import {Event} from '../storage/models/event'; import {IMessage} from '../message/i-message'; @Injectable() export class EventAddAction extends BaseAction { protected setEvent(): void { this.event = this.appEmitter.EVENT_ADD; } protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> { await this.storageService.markChatEventsInactive(chat); const eventDate: Date = parseEventDate(message.text.trim()); if (!eventDate) { return message.setStatus(statuses.STATUS_INVALID_DATE); } const event: Event = await this.storageService.appendChatActiveEvent( chat, eventDate, ); return message.setStatus(statuses.STATUS_SUCCESS).withData({ date: formatEventDate(event.date), }); } }
EventRemoveAction import {Injectable} from '@nestjs/common'; import * as statuses from './statuses'; import {formatEventDate} from '../common/utils'; import {BaseAction} from './base.action'; import {Chat} from '../storage/models/chat'; import {Event} from '../storage/models/event'; import {IMessage} from '../message/i-message'; @Injectable() export class EventRemoveAction extends BaseAction { protected setEvent(): void { this.event = this.appEmitter.EVENT_REMOVE; } protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> { const activeEvent: Event = await this.storageService.findChatActiveEvent( chat, ); await this.storageService.markChatEventsInactive(chat); if (activeEvent) { return message.setStatus(statuses.STATUS_SUCCESS).withData({ date: formatEventDate(activeEvent.date), }); } else { return message.setStatus(statuses.STATUS_NO_EVENT); } } }
EventInfoAction import {Injectable, Logger} from '@nestjs/common'; import * as statuses from './statuses'; import {formatEventDate} from '../common/utils'; import {ConfigService} from '../common/config.service'; import {AppEmitter} from '../common/event-bus.service'; import {TemplateService} from '../common/template.service'; import {StorageService} from '../storage/storage.service'; import {BaseAction} from './base.action'; import {PlayerHelper} from './player.helper'; import {Chat} from '../storage/models/chat'; import {Event} from '../storage/models/event'; import {Player} from '../storage/models/player'; import {IMessage} from '../message/i-message'; @Injectable() export class EventInfoAction extends BaseAction { private playerHelper: PlayerHelper; constructor( config: ConfigService, appEmitter: AppEmitter, logger: Logger, templateService: TemplateService, playerHelper: PlayerHelper, storageService: StorageService, ) { super(config, appEmitter, logger, templateService, storageService); this.playerHelper = playerHelper; } protected setEvent(): void { this.event = this.appEmitter.EVENT_INFO; } protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> { const activeEvent: Event = await this.storageService.findChatActiveEvent( chat, ); if (!activeEvent) { return message.setStatus(statuses.STATUS_NO_EVENT); } const players: Player[] = await this.storageService.getPlayers( activeEvent, ); return message.setStatus(statuses.STATUS_SUCCESS).withData({ date: formatEventDate(activeEvent.date), ...(await this.playerHelper.getPlayersList(activeEvent)), }); } }
PlayerAddAction import {Injectable, Logger} from '@nestjs/common'; import * as statuses from './statuses'; import {ConfigService} from '../common/config.service'; import {AppEmitter} from '../common/event-bus.service'; import {TemplateService} from '../common/template.service'; import {StorageService} from '../storage/storage.service'; import {BaseAction} from './base.action'; import {PlayerHelper} from './player.helper'; import {Chat} from '../storage/models/chat'; import {Event} from '../storage/models/event'; import {Player} from '../storage/models/player'; import {IMessage} from '../message/i-message'; @Injectable() export class PlayerAddAction extends BaseAction { private playerHelper: PlayerHelper; constructor( config: ConfigService, appEmitter: AppEmitter, logger: Logger, templateService: TemplateService, playerHelper: PlayerHelper, storageService: StorageService, ) { super(config, appEmitter, logger, templateService, storageService); this.playerHelper = playerHelper; } protected setEvent(): void { this.event = this.appEmitter.PLAYER_ADD; } protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> { const activeEvent: Event = await this.storageService.findChatActiveEvent( chat, ); if (!activeEvent) { return message.setStatus(statuses.STATUS_NO_EVENT); } const name: string = this.playerHelper.getPlayerName(message); const existedPlayer: Player = await this.storageService.findPlayer( activeEvent, name, ); if (existedPlayer) { return message .setStatus(statuses.STATUS_ALREADY_ADDED) .withData({name}); } const newPlayer: Player = await this.storageService.addPlayer( activeEvent, name, ); return message.setStatus(statuses.STATUS_SUCCESS).withData({ name: newPlayer.name, ...(await this.playerHelper.getPlayersList(activeEvent)), }); } }
PlayerRemoveAction import {Injectable, Logger} from '@nestjs/common'; import * as statuses from './statuses'; import {ConfigService} from '../common/config.service'; import {AppEmitter} from '../common/event-bus.service'; import {TemplateService} from '../common/template.service'; import {StorageService} from '../storage/storage.service'; import {BaseAction} from './base.action'; import {PlayerHelper} from './player.helper'; import {Chat} from '../storage/models/chat'; import {Event} from '../storage/models/event'; import {Player} from '../storage/models/player'; import {IMessage} from '../message/i-message'; @Injectable() export class PlayerRemoveAction extends BaseAction { private playerHelper: PlayerHelper; constructor( config: ConfigService, appEmitter: AppEmitter, logger: Logger, templateService: TemplateService, playerHelper: PlayerHelper, storageService: StorageService, ) { super(config, appEmitter, logger, templateService, storageService); this.playerHelper = playerHelper; } protected setEvent(): void { this.event = this.appEmitter.PLAYER_REMOVE; } protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> { const activeEvent: Event = await this.storageService.findChatActiveEvent( chat, ); if (!activeEvent) { return message.setStatus(statuses.STATUS_NO_EVENT); } const name: string = this.playerHelper.getPlayerName(message); const existedPlayer: Player = await this.storageService.findPlayer( activeEvent, name, ); if (!existedPlayer) { return message .setStatus(statuses.STATUS_NO_PLAYER) .withData({name}); } await this.storageService.removePlayer(existedPlayer); return message.setStatus(statuses.STATUS_SUCCESS).withData({ name, ...(await this.playerHelper.getPlayersList(activeEvent)), }); } }
O que você deve prestar atenção:
- Os comandos
/add
e /remove
adicionam / removem o player cujo nome vem após o comando. Se o nome não for especificado, o jogador que chamou a equipe será adicionado / removido. - Em resposta aos comandos
/add
, /remove
e /info
, uma lista atualizada de jogadores para o evento ativo é exibida.
A funcionalidade necessária para implementar (1) e (2) foi movida para uma classe auxiliar especial:
PlayerHelper import {Injectable} from '@nestjs/common'; import {StorageService} from '../storage/storage.service'; import {Event} from '../storage/models/event'; import {Player} from '../storage/models/player'; import {IMessage} from '../message/i-message'; @Injectable() export class PlayerHelper { protected storageService: StorageService; constructor(storageService: StorageService) { this.storageService = storageService; } public getPlayerName(message: IMessage) { const name = message.text.trim(); return name.length > 0 ? name : message.name; } public async getPlayersList(event: Event) { const players: Player[] = await this.storageService.getPlayers(event); return { total: players.length, players: players.map((player, index) => ({ index: index + 1, name: player.name, })), }; } }
Juntando tudo
Conforme mencionado no início do artigo, a estrutura do NestJS é usada como a estrutura do aplicativo. Ao mesmo tempo, tive a sorte de assistir pessoalmente ao relatório do criador desta maravilhosa biblioteca.
Muitos clichês, manuais e bibliotecas do NodeJS geralmente pecam por não oferecer nenhuma estratégia de inicialização sadia e conexões entre os módulos. Com o crescimento do aplicativo, na ausência de atenção adequada, a base de código se torna uma mistura de requisitos entre os módulos, geralmente levando a dependências ou relacionamentos cíclicos onde, em princípio, não deveriam existir. Dependency Injection awilix , NestJS .
DI , NodeJS , , .

Configuração
.
TELEGRAM_BOT_TOKEN
— Telegram .TELEGRAM_USE_PROXY
— TelegramAPI.TELEGRAM_PROXY_HOST
— TelegramAPI.TELEGRAM_PROXY_PORT
— TelegramAPI.TELEGRAM_PROXY_LOGIN
— TelegramAPI.TELEGRAM_PROXY_PASSWORD
— TelegramAPI.VK_TOKEN
— VK .DATABASE_URL
— . production .
:
TELEGRAM_BOT_TOKEN
Telegram.VK_TOKEN
— , VK . I.e. , . .DATABASE_URL
— production PostgreSQL. DBaaS elephantsql .
CI
" - — ". , TravisCI :
language: node_js node_js: - '8' - '10' - '12' script: - npm run lint - npm run build - npm run test:cov after_script: - npm install -g codecov - codecov
codecov .
Docker Docker Hub Gonf CI/CD GitHub Actions Python . Continuous Deployment, Github:
name: Release on: release: types: [published] jobs: build: runs-on: ubuntu-latest env: LOGIN: ${{ secrets.DOCKER_LOGIN }} NAME: ${{ secrets.DOCKER_NAME }} steps: - uses: actions/checkout@v1 - name: Install Node.js uses: actions/setup-node@v1 - name: Login to docker.io run: echo ${{ secrets.DOCKER_PWD }} | docker login -u ${{ secrets.DOCKER_LOGIN }} --password-stdin - name: npm install and build run: npm ci && npm run build - name: Build the Docker image run: docker build -t $LOGIN/$NAME:${GITHUB_REF:10} -f Dockerfile . - name: Push image to docker.io run: docker push $LOGIN/$NAME:${GITHUB_REF:10}
. Telegram :
.

.

.

VK <b>
<i>
.
.

.

Viber , . , Viber webhooks long-polling. , Viber . . (?) .
Telegram @Tormozz48bot
.
Github . , .
PS .