Bot pour les frais. Aller au football avec les nouvelles technologies

Présentation


Bonjour à tous. Dans cet article, je décrirai mon chatbot pour le service de messagerie par télégramme et le réseau social VK utilisant NodeJS.


À ce stade, de nombreux lecteurs devraient dire quelque chose comme: "Combien de temps!" ou "Quoi, encore?!"
Oui, des publications similaires étaient déjà sur un habr y compris. Mais, néanmoins, je pense que l'article sera utile. Brièvement sur ce que la mise en œuvre technique du bot représente:


  1. En tant que cadre pour l'application, le cadre NestJS gagne en popularité.
  2. La bibliothèque telegraf pour interagir avec l'API Telegram.
  3. La bibliothèque node-vk-bot-api pour interagir avec l'API VK.
  4. Bibliothèque de types pour organiser la couche de stockage de données.
  5. Tests utilisant mocha et la bibliothèque d'assertions chai.
  6. CI utilisant Travis CI pour tester et GitHub Actions pour déployer des images Docker.

En parallèle, essayons de faire de nos bots des amis avec Viber, le rendant ainsi universel pour une utilisation dans plusieurs services de messagerie.


Pour ceux qui veulent savoir ce qui en est arrivé, bienvenue au chat.


Énoncé du problème


En tant qu'activité physique utile, je préfère le football. Au niveau amateur, bien sûr. Étant membre de plusieurs chaînes et chats en vk, viber et télégramme, vous devez souvent voir l'image suivante:


image


Ce que nous voyons ici:


  1. "P" s'est ajouté.
  2. Après lui, 3 autres personnes ont été ajoutées, parmi lesquelles un certain "C".
  3. "P" a ajouté son camarade nommé "Dimon".
  4. Après cela, «P» n'était pas trop paresseux et a calculé qu'en ce moment il y avait déjà jusqu'à 5 personnes.
  5. Cependant, ces informations n'ont pas été pertinentes pendant longtemps, car j'ai également décidé de courir et de m'y ajouter.

Dans des cas particulièrement négligés, les gens peuvent mettre un «+» puis annuler leur participation, inviter des amis qui ne sont pas dans le chat, ou s'inscrire à d'autres participants au chat et faire d'autres activités. En fin de compte, cela conduit au fait que lorsque tout le monde finit par jouer, il s'avère que:


  1. Vasya a mis +1 signifiant non seulement lui-même, mais aussi son ami Gosh.
  2. Kolya a écrit qu'il viendrait, mais l'organisateur Spiridon n'a considéré avec ses yeux que les avantages.

En conséquence, nous avons peut-être un nombre inégalé d'équipes, «extra Gosh» et Kolya est apparu de manière inattendue.


Afin d'éviter de telles collisions, j'ai écrit un bot simple qui aide à afficher visuellement la composition des participants du jeu à venir et est pratique pour y ajouter / supprimer / supprimer.


Donc, dans mon implémentation de base, à mon avis, le bot devrait être capable de:


  1. Soyez intégré dans n'importe quel nombre de chats. Enregistrez votre état pour chaque chat séparément.
  2. À l'aide de la commande / event_add, créez un nouvel événement actif avec certaines informations et une liste vide de joueurs. L'événement actif précédent doit devenir inactif.
  3. La commande / event_remove doit annuler l'événement actuellement actif.
  4. La commande / add doit ajouter un nouveau membre à l'événement actuellement actif. De plus, si la commande est appelée sans texte supplémentaire, celui qui a tapé la commande doit être ajouté. Sinon, une personne est ajoutée dont le nom est spécifié lors de l'appel de la commande.
  5. La commande / remove supprime une personne de la liste des participants selon les règles décrites pour la commande / add .
  6. La commande / info vous permet de voir qui s'est déjà inscrit au jeu.
  7. Il est hautement souhaitable que le bot réponde dans la même langue que celle configurée par le joueur (ou en anglais par défaut).

Pour voir rapidement ce qui s'est passé à la fin, vous pouvez immédiatement accéder à la dernière section "Résultat et conclusions". Eh bien, si vous êtes intéressé par les détails de mise en œuvre, ces informations sont décrites suffisamment en détail ci-dessous.


Conception et développement


Lors de la conception de l'application, le schéma suivant est apparu dans ma tête:


image


Ici, l'idée principale était d'intégrer toutes les spécificités du travail avec divers systèmes de messagerie dans les adaptateurs appropriés et d'encapsuler la logique d'interaction avec les bibliothèques implémentant les API correspondantes, traitant et regroupant les données sous une forme unique.


Modèles d'interface et de message


Pour les messages, il a été possible de concevoir l'interface IMessage suivante, qui est satisfaite par les classes qui stockent les données des messages entrants pour divers systèmes et méthodes pour travailler avec ces données:


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 classe de base BaseMessage qui implémente cette interface a la forme suivante:


Message de 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 message pour 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); } } 

et pour 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; } } 

À quoi devez-vous faire attention:


  1. chatId est l'identifiant unique du chat. Pour Telegram, cela vient explicitement dans la structure de chaque message. Cependant, dans le cas de VK, un tel identifiant n'existe pas explicitement. Comment être? Pour répondre à cette question, vous devez comprendre le fonctionnement du bot dans VK. Dans ce système, un bot en correspondance de groupe agit au nom de la communauté pour laquelle il démarre. C'est-à-dire chaque message de bot contient un identifiant de communauté group_id . De plus, peer_id vient dans le message (plus de détails peuvent être lus ici ) comme identifiant de la conversation de groupe dans notre cas. Sur la base de ces deux identifiants, vous pouvez créer votre identifiant de chat, par exemple comme suit:


     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; } 

    Cette méthode n'est certes pas parfaite, mais applicable à la tâche en cours.


  2. texte intégral et texte . Comme le nom des variables l' indique , fullText contient le texte intégral du message, tandis que le texte ne contient que la partie qui vient après le nom de la commande.



Ceci est pratique car il vous permet d'utiliser le champ de texte prêt à l'emploi pour analyser les informations auxiliaires qu'il contient, telles que la date de l'événement ou le nom du joueur.


  1. Contrairement à Telegram, les messages VK ne prennent pas en charge un ensemble de balises pour mettre en surbrillance du texte comme <b> ou <i> , par conséquent, lorsque vous répondez, ces balises doivent être supprimées à l'aide d'expressions régulières.

Adaptateurs pour recevoir et envoyer des messages


Après avoir créé l'interface et les structures de données pour les messages Telegram et VK, le moment est venu de mettre en œuvre des services pour le traitement des événements entrants.


image


Ces services doivent initialiser des modules tiers pour interagir avec les API Telegram et VK et lancer des mécanismes d'interrogation longue, ainsi qu'une connexion entre les commandes entrantes et les événements internes qui se produisent dans le système lors de la réception de données des services de messagerie.


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], ]; } } 

À quoi devez-vous faire attention:


  1. En raison de certains problèmes d'accès au service Telegram (salut Roskomnadzor), il était nécessaire d'utiliser éventuellement un proxy qui fournit le package client socks5-https .
  2. Lors du traitement d'un événement, un champ est ajouté au contexte avec la valeur de la commande avec le texte dont le message a été reçu.
  3. Pour VK, vous devez télécharger séparément les données de l'utilisateur qui a envoyé le message à l'aide d'un appel d'API distinct:
     const [from] = await this.bot.execute('users.get', { user_ids: ctx.message.from_id, }); 

Modèle de données


Pour implémenter la fonctionnalité déclarée du bot, trois modèles étaient suffisants:


  1. Chat - chat ou conversation.
  2. Event - un événement pour un chat programmé à une date et une heure spécifiques.
  3. Player - participant ou joueur participant à l'événement.

Schéma de données


Les implémentations de modèles utilisant la bibliothèque de types sont:


Clavarder
 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[]; } 

Événement
 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[]; } 

Joueur
 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; } 

Ici, il est nécessaire de discuter une fois de plus du mécanisme du bot pour différentes équipes en termes de manipulation des modèles de chat , d' événement et de joueur .


  1. À la réception de toute commande, l'existence d'une conversation dans la base de données est vérifiée avec chatId . S'il n'y a pas d'enregistrement correspondant, il est créé.
  2. Pour simplifier la logique, chaque conversation ne peut avoir qu'un seul événement actif ( événement ). C'est-à-dire à la /event_add commande /event_add , un nouvel événement actif avec la date spécifiée est créé dans le système
    L'événement actif actuel (s'il existe) devient inactif.
  3. /event_remove trouve l'événement actif dans le chat en cours et le désactive.
  4. /info trouve un événement actif dans le chat en cours, affiche des informations sur cet événement et une liste de joueurs ( Player ).
  5. /add et /remove travail avec l'événement actif en ajoutant et en supprimant des joueurs.

Le service correspondant pour travailler avec des données est le suivant:


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); } } 

Réponse du modèle


En plus du service StorageService décrit pour travailler avec des données, il était également nécessaire d'implémenter un service TemplateService dédié dont les tâches sont actuellement:


  1. Téléchargez et compilez des modèles de guidons pour les options de réponse pour diverses commandes dans différentes langues.
  2. Sélection d'un modèle approprié en fonction de l'événement actuel, de l'état de la réponse et de la langue de l'utilisateur.
  3. Remplir le modèle avec les données obtenues à la suite de la commande.

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}`; } } 

À quoi devez-vous faire attention:


  1. Les fichiers de modèle se trouvent dans un répertoire ./templates distinct à la racine du projet et sont structurés par langue, action (commande) et statut de réponse.


     - templates - en - event_add - invalid_date.hbs - success.hbs - event_info - ru 

    Lorsque l'application est initialisée, tous les modèles de réponse sont chargés en mémoire et remplissent une carte avec des clés qui identifient de manière unique le modèle:


     private getTemplateKey(lang: string, action: string, status: string): string { return `${lang}-${action}-${status}`; } 

  2. Actuellement, le système dispose de 2 ensembles de modèles: pour le russe et l'anglais.
    Le remplacement est fourni dans la langue par défaut (anglais) en l'absence du modèle nécessaire pour la langue de l'événement en cours.


  3. Modèles de guidons eux-mêmes, par exemple:


     Player <strong>{{name}}</strong> will take part in the game List of players: {{#each players}} {{index}}: <i>{{name}}</i> {{/each}} Total: <strong>{{total}}</strong> 

    contiennent à la fois des espaces réservés traditionnels et un ensemble de balises valides pour formater les réponses dans Telegram.



Services de traitement des commandes (actions)


Maintenant que les services d'assistance de base sont décrits, il est temps de passer à la mise en œuvre de la logique métier du bot elle-même. Schématiquement, la connexion entre les modules est présentée ici:


les actions


Les tâches de la classe de base BaseAction incluent:


  1. Abonnez-vous à un événement spécifique d' AppEmitter .
  2. La fonctionnalité globale du traitement des événements, à savoir:
    • rechercher un existant ou créer un nouveau chat dans le cadre duquel l'équipe sera traitée.
    • Un appel à la méthode du modèle doAction implémentation est individuelle pour chaque classe de la BaseAction héritée .
    • Application de modèles à la réponse doAction résultante à l'aide de TemplateService .

Action de 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 tâche des classes enfants BaseAction consiste à exécuter la méthode doAction de l'entrée:


  1. Chat créé ou défini dans la classe de base
  2. Un objet conforme au protocole IMessage .

À la suite de l'exécution de cette méthode, IMessage est également renvoyé, mais avec le statut établi pour choisir le bon modèle et les données qui participeront au modèle de réponse.


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)), }); } } 

À quoi devez-vous faire attention:


  1. Les commandes /add et /remove ajoutent / suppriment le lecteur dont le nom vient après la commande. Si le nom n'est pas spécifié, le joueur qui a appelé l'équipe est ajouté / supprimé.
  2. En réponse aux commandes /add , /remove et /info , une liste mise à jour des joueurs pour l'événement actif s'affiche.

La fonctionnalité requise pour implémenter (1) et (2) a été déplacée vers une classe auxiliaire spéciale:


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, })), }; } } 

Tout mettre ensemble


Comme mentionné au début de l'article, le cadre NestJS est utilisé comme cadre d'application. À un moment donné, j'ai eu la chance d'assister personnellement au rapport du créateur de cette merveilleuse bibliothèque.


De nombreux passe-partout, manuels et bibliothèques NodeJS pèchent souvent en n'offrant aucune stratégie d'initialisation saine et aucune connexion entre les modules. Avec la croissance de l'application, en l'absence d'une attention appropriée, la base de code devient un méli-mélo d'exigences entre les modules, conduisant souvent même à des dépendances cycliques ou à des relations où, en principe, elles ne devraient pas l'être. Dependency Injection awilix , NestJS .


DI , NodeJS , , .


structure générale des modules



.


  1. TELEGRAM_BOT_TOKEN — Telegram .
  2. TELEGRAM_USE_PROXY — TelegramAPI.
  3. TELEGRAM_PROXY_HOST — TelegramAPI.
  4. TELEGRAM_PROXY_PORT — TelegramAPI.
  5. TELEGRAM_PROXY_LOGIN — TelegramAPI.
  6. TELEGRAM_PROXY_PASSWORD — TelegramAPI.
  7. VK_TOKEN — VK .
  8. DATABASE_URL — . production .

:


  1. TELEGRAM_BOT_TOKEN Telegram.
  2. VK_TOKEN — , VK . C'est-à-dire , . .
  3. 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 :
.


demo1


.


demo2


.


demo3


VK <b> <i> .
.


demo4


.


demo6


Viber , . , Viber webhooks long-polling. , Viber . . (?) .


Telegram @Tormozz48bot .


Github . , .


PS .

Source: https://habr.com/ru/post/fr483194/


All Articles