Bot untuk biaya. Pergi ke sepakbola dengan teknologi baru

Pendahuluan


Halo semuanya. Pada artikel ini saya akan menjelaskan chatbot saya untuk layanan pesan telegram dan jejaring sosial VK menggunakan NodeJS.


Pada titik ini, banyak pembaca harus mengeluarkan sesuatu seperti: "Berapa lama!" atau "Apa, lagi?!"
Ya, publikasi serupa sudah ada di habr termasuk. Tetapi, bagaimanapun, saya percaya bahwa artikel itu akan bermanfaat. Secara singkat tentang apa yang diwakili oleh implementasi teknis dari bot:


  1. Sebagai kerangka kerja untuk aplikasi, kerangka kerja NestJS semakin populer.
  2. Pustaka telegraf untuk berinteraksi dengan API Telegram.
  3. Pustaka node-vk-bot-api untuk berinteraksi dengan VK API.
  4. Pustaka typeorm untuk mengatur lapisan penyimpanan data.
  5. Tes menggunakan moka dan perpustakaan menegaskan chai.
  6. CI menggunakan Travis CI untuk pengujian dan Tindakan GitHub untuk menyebarkan gambar buruh pelabuhan.

Sebagai pekerjaan sampingan, mari kita coba membuat bot kita berteman dengan Viber, membuatnya sangat universal untuk digunakan di beberapa layanan perpesanan.


Bagi mereka yang ingin tahu apa yang terjadi, selamat datang di kucing.


Pernyataan masalah


Sebagai aktivitas fisik yang bermanfaat, saya lebih suka sepak bola. Pada level amatir, tentu saja. Menjadi anggota dari beberapa saluran dan obrolan dalam vk, viber dan telegram, Anda sering harus melihat gambar berikut:


gambar


Apa yang kita lihat di sini:


  1. "P" tambahnya sendiri.
  2. Setelah dia, 3 orang lagi ditambahkan, di antaranya adalah "C" tertentu.
  3. "P" menambahkan kawannya bernama "Dimon".
  4. Setelah itu, "P" tidak terlalu malas dan menghitung bahwa saat ini sudah ada sebanyak 5 orang.
  5. Namun, informasi ini tidak relevan lama, karena saya juga memutuskan untuk menjalankan dan menambahkan sendiri.

Dalam kasus-kasus yang terabaikan, orang-orang dapat memberikan tanda "+" dan kemudian membatalkan partisipasi mereka, mengundang teman yang bukan dari obrolan, atau mendaftar untuk peserta obrolan lain dan melakukan kegiatan lain. Pada akhirnya, ini mengarah pada fakta bahwa ketika semua orang akhirnya datang untuk bermain ternyata:


  1. Vasya mengatur +1 yang berarti tidak hanya dirinya sendiri, tetapi juga temannya Astaga.
  2. Kolya menulis bahwa dia akan datang, tetapi penyelenggara Spiridon menganggap dengan matanya hanya kelebihannya.

Akibatnya, kami mungkin memiliki ketidaksetaraan dalam jumlah tim, "astaga" dan tiba-tiba muncul Kolya.


Untuk menghindari tabrakan seperti itu, saya menulis bot sederhana yang membantu menampilkan secara visual komposisi peserta game yang akan datang dan nyaman untuk menambahkan (s) / menghapus (s) ke dalamnya.


Jadi, dalam implementasi dasar saya, menurut pendapat saya, bot harus dapat:


  1. Jadilah tertanam dalam sejumlah obrolan. Simpan status Anda untuk setiap obrolan secara terpisah.
  2. Menggunakan perintah / event_add, buat acara aktif baru dengan informasi tertentu dan daftar pemain kosong. Acara aktif sebelumnya harus menjadi tidak aktif.
  3. Perintah / event_remove harus membatalkan acara yang sedang aktif.
  4. Perintah / add harus menambahkan anggota baru ke acara yang sedang aktif. Selain itu, jika perintah dipanggil tanpa teks tambahan, maka orang yang mengetik perintah harus ditambahkan. Kalau tidak, seseorang ditambahkan yang namanya ditentukan ketika perintah dipanggil.
  5. Perintah / remove menghapus seseorang dari daftar peserta sesuai dengan aturan yang dijelaskan untuk perintah / add .
  6. Perintah / info memungkinkan Anda untuk melihat siapa yang sudah mendaftar untuk game.
  7. Sangat diinginkan bahwa bot merespons dalam bahasa yang sama dengan yang dikonfigurasikan pemain (atau dalam bahasa Inggris secara default).

Untuk melihat dengan cepat apa yang terjadi pada akhirnya, Anda dapat langsung pergi ke bagian terakhir "Hasil dan kesimpulan." Nah, jika Anda tertarik dengan detail implementasi, maka informasi ini dijelaskan dalam detail yang cukup di bawah ini.


Desain dan pengembangan


Saat merancang aplikasi, skema berikut muncul di kepala saya:


gambar


Di sini, ide utamanya adalah untuk membawa semua spesifik bekerja dengan berbagai sistem pesan ke adaptor yang sesuai dan merangkum logika interaksi dengan perpustakaan yang menerapkan API yang sesuai, memproses dan membawa data ke satu bentuk.


Model antarmuka dan pesan


Untuk pesan, dimungkinkan untuk merancang antarmuka IMessage berikut, yang dipenuhi oleh kelas yang menyimpan data pesan masuk untuk berbagai sistem dan metode untuk bekerja dengan data ini:


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

Kelas dasar BaseMessage yang mengimplementasikan antarmuka ini memiliki bentuk berikut:


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

Kelas pesan untuk 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); } } 

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

Apa yang harus Anda perhatikan:


  1. chatId adalah pengidentifikasi unik untuk obrolan. Untuk Telegram, ia datang secara eksplisit dalam struktur setiap pesan. Namun, dalam kasus VK tidak ada pengidentifikasi seperti itu secara eksplisit. Bagaimana menjadi? Untuk menjawab pertanyaan ini, Anda perlu memahami bagaimana fungsi bot dalam VK. Dalam sistem ini, bot dalam korespondensi grup bertindak atas nama komunitas yang dimulainya. Yaitu setiap pesan bot berisi pengidentifikasi komunitas group_id . Selain itu, peer_id hadir dalam pesan (detail selengkapnya dapat dibaca di sini ) sebagai pengidentifikasi percakapan grup dalam kasus kami. Berdasarkan dua pengidentifikasi ini, Anda dapat membuat pengenal obrolan Anda, misalnya sebagai berikut:


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

    Metode ini tentu saja tidak sempurna, tetapi berlaku untuk tugas saat ini.


  2. teks lengkap dan teks . Seperti yang ditunjukkan oleh nama variabel, fullText berisi teks lengkap dari pesan, sementara teks hanya berisi bagian yang muncul setelah nama perintah.



Ini nyaman karena memungkinkan Anda untuk menggunakan bidang teks yang sudah jadi untuk mengurai informasi tambahan yang terkandung di dalamnya, seperti tanggal acara atau nama pemain.


  1. Tidak seperti Telegram, pesan VK tidak mendukung serangkaian tag untuk menyorot teks seperti <b> atau <i> , oleh karena itu, ketika merespons, tag ini harus dihapus menggunakan ekspresi reguler.

Adaptor untuk menerima dan mengirim pesan


Setelah membuat antarmuka dan struktur data untuk pesan Telegram dan VK, saatnya untuk mengimplementasikan layanan untuk memproses acara yang masuk.


gambar


Layanan ini harus menginisialisasi modul pihak ketiga untuk berinteraksi dengan Telegram dan VK API dan meluncurkan mekanisme pemungutan suara panjang, serta memiliki koneksi antara perintah yang masuk dan peristiwa internal yang terjadi dalam sistem ketika menerima data dari layanan pesan.


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

Apa yang harus Anda perhatikan:


  1. Karena masalah tertentu dengan akses ke layanan Telegram (hi Roskomnadzor), saya harus menggunakan proxy yang menyediakan paket socks5-https-client .
  2. Saat memproses suatu peristiwa, bidang ditambahkan ke konteks dengan nilai perintah dengan teks yang pesannya diterima.
  3. Untuk VK, Anda harus mengunduh secara terpisah data pengguna yang mengirim pesan menggunakan panggilan API terpisah:
     const [from] = await this.bot.execute('users.get', { user_ids: ctx.message.from_id, }); 

Model data


Untuk mengimplementasikan fungsionalitas yang dinyatakan dari bot, tiga model sudah cukup:


  1. Chat - obrolan atau percakapan.
  2. Event - acara untuk obrolan yang dijadwalkan untuk tanggal dan waktu tertentu.
  3. Player - peserta atau pemain yang berpartisipasi dalam acara tersebut.

Data skema


Implementasi model menggunakan pustaka typeorm adalah:


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

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

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

Di sini perlu sekali lagi membahas mekanisme bot untuk berbagai tim dalam hal memanipulasi model Obrolan , Acara , dan Pemain .


  1. Setelah menerima perintah apa pun, keberadaan obrolan di DB diperiksa dengan chatId . Jika tidak ada catatan yang sesuai, maka itu dibuat.
  2. Untuk menyederhanakan logika, setiap obrolan hanya dapat memiliki satu acara aktif ( Peristiwa ). Yaitu setelah menerima /event_add , acara aktif baru dibuat dalam sistem dengan tanggal yang ditentukan.
    Acara aktif saat ini (jika ada) menjadi tidak aktif.
  3. /event_remove menemukan acara aktif di obrolan saat ini dan menonaktifkannya.
  4. /info menemukan acara aktif di obrolan saat ini, menampilkan informasi tentang acara ini dan daftar pemain ( Pemain ).
  5. /add dan /remove pekerjaan dengan acara aktif menambah dan menghapus pemain dari itu.

Layanan yang sesuai untuk bekerja dengan data adalah sebagai berikut:


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

Respons templat


Selain layanan StorageService yang dijelaskan untuk bekerja dengan data, ada juga kebutuhan untuk mengimplementasikan layanan TemplateService khusus yang tugasnya saat ini adalah:


  1. Unduh dan kompilasi template setang untuk opsi jawaban untuk berbagai perintah dalam berbagai bahasa.
  2. Memilih templat yang sesuai tergantung pada peristiwa terkini, status respons, dan bahasa pengguna.
  3. Mengisi templat dengan data yang diperoleh sebagai hasil dari perintah.

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

Apa yang harus Anda perhatikan:


  1. File templat berada dalam direktori ./templates terpisah pada akar proyek dan disusun berdasarkan bahasa, tindakan (perintah), dan status respons.


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

    Ketika aplikasi diinisialisasi, semua templat respons dimuat ke dalam memori dan mengisi Peta dengan kunci yang secara unik mengidentifikasi templat:


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

  2. Saat ini, sistem memiliki 2 set templat: untuk Rusia dan Inggris.
    Fallback disediakan dalam bahasa default (Inggris) tanpa adanya template yang diperlukan untuk bahasa acara saat ini.


  3. Gagang template sendiri, misalnya:


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

    berisi penampung tradisional dan satu set tag yang valid untuk memformat respons di Telegram.



Layanan Pemrosesan Perintah (Tindakan)


Sekarang setelah layanan dukungan dasar dijelaskan, saatnya beralih ke penerapan logika bisnis bot itu sendiri. Secara skematis, koneksi antar modul disajikan di sini:


tindakan


Tugas kelas dasar BaseAction meliputi:


  1. Berlangganan acara tertentu dari AppEmitter .
  2. Fungsi keseluruhan dari pemrosesan acara, yaitu:
    • mencari yang sudah ada atau membuat obrolan baru dalam konteks di mana tim akan diproses
    • Panggilan ke metode templat doAction implementasinya bersifat individual untuk setiap kelas BaseAction yang mewarisi.
    • Menerapkan templat ke respons doAction dihasilkan menggunakan doAction .

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

Tugas kelas anak BaseAction adalah untuk mengeksekusi metode doAction dari input:


  1. Obrolan yang dibuat atau didefinisikan di kelas dasar
  2. Objek yang mematuhi protokol IMessage .

Sebagai hasil dari pelaksanaan metode ini, IMessage juga dikembalikan, tetapi dengan status yang ditetapkan untuk memilih templat yang tepat dan data yang akan berpartisipasi dalam templat respons.


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

Apa yang harus Anda perhatikan:


  1. Perintah /add dan /remove tambahkan / hapus pemain yang namanya muncul setelah perintah. Jika nama tidak ditentukan, maka pemain yang memanggil tim ditambahkan / dihapus.
  2. Menanggapi /add , /remove dan /info perintah, daftar pemain yang diperbarui untuk acara aktif ditampilkan.

Fungsi yang diperlukan untuk mengimplementasikan (1) dan (2) dipindahkan ke kelas bantu khusus:


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

Menyatukan semuanya


Sebagaimana disebutkan di awal artikel, kerangka kerja NestJS digunakan sebagai kerangka kerja aplikasi. Pada suatu waktu, saya beruntung secara pribadi menghadiri laporan dari pencipta perpustakaan yang luar biasa ini.


Banyak pelat NodeJS, manual, dan perpustakaan sering berbuat dosa dengan tidak menawarkan strategi inisialisasi yang waras dan koneksi antar modul. Dengan tumbuhnya aplikasi, dengan tidak adanya perhatian yang tepat, basis kode menjadi campuran kebutuhan antara modul, sering mengarah bahkan ke ketergantungan siklik atau hubungan di mana, pada prinsipnya, mereka seharusnya tidak. Dependency Injection awilix , NestJS .


DI , NodeJS , , .


struktur umum modul



.


  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 . Yaitu , . .
  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/id483194/


All Articles