Introduccion
Hola a todos En este artículo describiré mi chatbot para el servicio de mensajería de telegramas y la red social VK usando NodeJS.
En este punto, muchos lectores deberían decir algo como: "¡Cuánto tiempo!" o "¿Qué otra vez?"
Sí, publicaciones similares ya estaban en un habr incluido. Pero, sin embargo, creo que el artículo será útil. Brevemente sobre lo que representa la implementación técnica del bot:
- Como marco para la aplicación, el marco NestJS está ganando popularidad.
- La biblioteca de telegraf para interactuar con la API de Telegram.
- La biblioteca node-vk-bot-api para interactuar con la API de VK.
- Biblioteca Typeorm para organizar la capa de almacenamiento de datos.
- Pruebas con mocha y la biblioteca de afirmación chai.
- CI utilizando Travis CI para pruebas y acciones de GitHub para desplegar imágenes acoplables.
Como trabajo adicional, tratemos de hacer que nuestros bots sean amigos de Viber, haciéndolo tan universal para su uso en varios servicios de mensajería.
Para aquellos que quieran saber de qué se trata, bienvenidos a cat.
Declaración del problema.
Como actividad física útil, prefiero el fútbol. En un nivel aficionado, por supuesto. Como miembro de varios canales y chats en vk, viber y telegram, a menudo tiene que ver la siguiente imagen:

Lo que vemos aquí:
- "P" se agregó a sí mismo.
- Después de él, se agregaron 3 personas más, entre las cuales había una cierta "C".
- "P" agregó su compañero llamado "Dimon".
- Después de eso, "P" no fue demasiado vago y calculó que en este momento ya hay hasta 5 personas.
- Sin embargo, esta información no fue relevante por mucho tiempo, ya que también decidí correr y agregarme.
En casos especialmente descuidados, las personas pueden poner un "+" y luego cancelar su participación, invitar a amigos que no participan en el chat o suscribirse a otros participantes del chat y realizar otras actividades. Al final, esto lleva al hecho de que cuando todos finalmente vienen a jugar, resulta que:
- Vasya estableció +1 significando no solo a sí mismo, sino también a su amigo Gosh.
- Kolya escribió que vendría, pero el organizador Spiridon consideró con sus ojos solo las ventajas.
Como resultado, posiblemente tengamos una cantidad desigual de equipos, "Gosh extra" y inesperadamente apareció Kolya.
Para evitar tales colisiones, escribí un bot simple que ayuda a mostrar visualmente la composición de los participantes del próximo juego y es conveniente agregarlo (s) / eliminarlo (s).
Entonces, en mi implementación básica, en mi opinión, el bot debería poder:
- Estar incrustado en cualquier número de chats. Almacene su estado para cada chat por separado.
- Usando el comando / event_add, cree un nuevo evento activo con cierta información y una lista vacía de jugadores. El evento activo anterior debería quedar inactivo.
- El comando / event_remove debería cancelar el evento actualmente activo.
- El comando / add debería agregar un nuevo miembro al evento actualmente activo. Además, si se llama al comando sin ningún texto adicional, se debe agregar el que escribió el comando. De lo contrario, se agrega una persona cuyo nombre se especifica cuando se llama al comando.
- El comando / remove elimina a una persona de la lista de participantes de acuerdo con las reglas descritas para el comando / add .
- El comando / info te permite ver quién ya se ha registrado en el juego.
- Es altamente deseable que el bot responda en el mismo idioma que el jugador configurado (o en inglés de forma predeterminada).
Para ver rápidamente lo que sucedió al final, puede ir inmediatamente a la última sección "Resultado y conclusiones". Bueno, si está interesado en los detalles de implementación, esta información se describe con suficiente detalle a continuación.
Diseño y desarrollo
Al diseñar la aplicación, el siguiente esquema apareció en mi cabeza:

Aquí, la idea principal era incorporar todos los detalles específicos de trabajar con varios sistemas de mensajería en los adaptadores apropiados y encapsular la lógica de interacción con las bibliotecas que implementan las API correspondientes, procesando y llevando los datos a una sola forma.
Modelos de interfaz y mensaje
Para los mensajes, fue posible diseñar la siguiente interfaz de IMessage , que satisface las clases que almacenan datos de mensajes entrantes para varios sistemas y métodos para trabajar con estos datos:
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; }
La clase base BaseMessage que implementa esta interfaz tiene la siguiente forma:
Mensaje 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'); } } }
Clase de mensaje para 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); } }
y 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; } }
A qué debe prestar atención:
chatId es el identificador único para el chat. Para Telegram, viene explícitamente en la estructura de cada mensaje. Sin embargo, en el caso de VK no existe dicho identificador explícitamente. Como ser Para responder a esta pregunta, debe comprender cómo funciona el bot dentro de VK. En este sistema, un bot en correspondencia grupal actúa en nombre de la comunidad para la que comienza. Es decir cada mensaje bot contiene un identificador de comunidad group_id . Además, peer_id viene en el mensaje (más detalles se pueden leer aquí ) como el identificador de la conversación grupal en nuestro caso. En base a estos dos identificadores, puede crear su identificador de chat, por ejemplo, de la siguiente manera:
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; }
Este método ciertamente no es perfecto, pero es aplicable a la tarea actual.
texto completo y texto . Como lo indica el nombre de las variables, fullText contiene el texto completo del mensaje, mientras que el texto contiene solo la parte que viene después del nombre del comando.
Esto es conveniente porque le permite usar el campo de texto listo para analizar la información auxiliar contenida en él, como la fecha del evento o el nombre del jugador.
- A diferencia de Telegram, los mensajes VK no admiten un conjunto de etiquetas para resaltar texto como
<b>
o <i>
, por lo tanto, al responder, estas etiquetas deben eliminarse utilizando expresiones regulares.
Adaptadores para recibir y enviar mensajes.
Después de crear la interfaz y las estructuras de datos para los mensajes de Telegram y VK, ha llegado el momento de implementar servicios para procesar eventos entrantes.

Estos servicios deben inicializar módulos de terceros para interactuar con las API de Telegram y VK y lanzar mecanismos de sondeo largos, así como tener una conexión entre los comandos entrantes y los eventos internos que ocurren en el sistema cuando se reciben datos de los servicios de mensajería.
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], ]; } }
A qué debe prestar atención:
- Debido a ciertos problemas con el acceso al servicio de Telegram (hola Roskomnadzor), fue necesario utilizar opcionalmente un proxy que proporciona el paquete socks5-https-client .
- Al procesar un evento, se agrega un campo al contexto con el valor del comando con el texto del cual se recibió el mensaje.
- Para VK, debe descargar por separado los datos del usuario que envió el mensaje mediante una llamada API separada:
const [from] = await this.bot.execute('users.get', { user_ids: ctx.message.from_id, });
Modelo de datos
Para implementar la funcionalidad declarada del bot, tres modelos fueron suficientes:
Chat
: chat o conversación.Event
: un evento para un chat programado para una fecha y hora específicas.Player
: participante o jugador que participa en el evento.

Las implementaciones de modelos que usan la biblioteca typeorm son:
Chatear 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[]; }
El 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[]; }
Jugador 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; }
Aquí es necesario discutir una vez más el mecanismo del bot para varios equipos en términos de manipulación de modelos de Chat , Evento y Jugador .
- Al recibir cualquier comando, se verifica la existencia de un chat en el DB con
chatId
. Si no hay un registro correspondiente, se crea. - Para simplificar la lógica, cada chat solo puede tener un evento activo ( Evento ). Es decir Al recibir el
/event_add
, se crea un nuevo evento activo en el sistema con la fecha especificada.
El evento activo actual (si existe) se vuelve inactivo. /event_remove
encuentra el evento activo en el chat actual y lo desactiva./info
encuentra un evento activo en el chat actual, muestra información sobre este evento y una lista de jugadores ( Player )./add
and /remove
funciona con el evento activo agregando y eliminando jugadores.
El servicio correspondiente para trabajar con datos es el siguiente:
Servicio de almacenamiento 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); } }
Respuesta de plantilla
Además del servicio StorageService descrito para trabajar con datos, también era necesario implementar un servicio TemplateService dedicado cuyas tareas en este momento son:
- Descargue y compile plantillas de manillar para opciones de respuesta para varios comandos en diferentes idiomas.
- Seleccionar una plantilla adecuada según el evento actual, el estado de respuesta y el idioma del usuario.
- Llenar la plantilla con datos obtenidos como resultado del 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}`; } }
A qué debe prestar atención:
Los archivos de plantilla están en un directorio ./templates
separado en la raíz del proyecto y están estructurados por lenguaje, acción (comando) y estado de respuesta.
- templates - en - event_add - invalid_date.hbs - success.hbs - event_info - ru
Cuando se inicializa la aplicación, todas las plantillas de respuesta se cargan en la memoria y pueblan un mapa con claves que identifican de forma única la plantilla:
private getTemplateKey(lang: string, action: string, status: string): string { return `${lang}-${action}-${status}`; }
Actualmente, el sistema tiene 2 conjuntos de plantillas: para ruso e inglés.
El respaldo se proporciona en el idioma predeterminado (inglés) en ausencia de la plantilla necesaria para el idioma del evento actual.
Plantillas de manillar en sí, por ejemplo:
Player <strong>{{name}}</strong> will take part in the game List of players: {{#each players}} {{index}}: <i>{{name}}</i> {{/each}} Total: <strong>{{total}}</strong>
contienen marcadores de posición tradicionales y un conjunto de etiquetas válidas para formatear respuestas en Telegram.
Servicios de procesamiento de comandos (acciones)
Ahora que se describen los servicios de soporte básicos, es hora de pasar a implementar la lógica comercial del bot. Esquemáticamente, la conexión entre los módulos se presenta aquí:

Las tareas de la clase base BaseAction incluyen:
- Suscríbase a un evento específico de AppEmitter .
- La funcionalidad general del procesamiento de eventos, a saber:
- buscando uno existente o creando un nuevo chat en el contexto del cual se procesará el equipo.
- Una llamada al método de plantilla
doAction
implementación es individual para cada clase de BaseAction heredado. - Aplicando plantillas a la respuesta de
doAction
resultante usando TemplateService .
Acción 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); } } }
La tarea de las clases secundarias BaseAction es ejecutar el método doAction
de la entrada:
- Chat creado o definido en la clase base
- Un objeto que cumple con el protocolo IMessage .
Como resultado de ejecutar este método, también se devuelve IMessage , pero con el estado establecido para elegir la plantilla correcta y los datos que participarán en la plantilla de respuesta.
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)), }); } }
A qué debe prestar atención:
- Los comandos
/add
y /remove
agregan / eliminan el jugador cuyo nombre viene después del comando. Si no se especifica el nombre, el jugador que llamó al equipo es agregado / eliminado. - En respuesta a los comandos
/add
, /remove
y /info
, se muestra una lista actualizada de jugadores para el evento activo.
La funcionalidad requerida para implementar (1) y (2) se movió a una clase 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, })), }; } }
Poniendo todo junto
Como se mencionó al principio del artículo, el marco NestJS se usa como marco de la aplicación. En un momento, tuve la suerte de asistir personalmente al informe del creador de esta maravillosa biblioteca.
Muchas repeticiones, manuales y bibliotecas de NodeJS a menudo pecan al no ofrecer estrategias de inicialización sensatas y conexiones entre módulos. Con el crecimiento de la aplicación, en ausencia de la atención adecuada, la base del código se convierte en una mezcla de requisitos entre los módulos, lo que a menudo conduce a dependencias o relaciones cíclicas donde, en principio, no deberían existir. Dependency Injection awilix , NestJS .
DI , NodeJS , , .

Configuracion
.
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 . Es decir , . .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 .