بوت للرسوم. الذهاب إلى كرة القدم مع التقنيات الجديدة

مقدمة


مرحبا بالجميع. في هذه المقالة سوف أصف chatbot الخاص بي لخدمة رسائل البرق والشبكة الاجتماعية VK باستخدام NodeJS.


عند هذه النقطة ، يجب على العديد من القراء الخروج بشيء مثل: "كم من الوقت!" أو "ماذا ، مرة أخرى؟!"
نعم ، منشورات مماثلة بالفعل على habr بما في ذلك. لكن ، مع ذلك ، أعتقد أن المقال سيكون مفيدًا. باختصار حول ما يمثله التنفيذ الفني للروبوت:


  1. كإطار للتطبيق ، يكتسب إطار عمل NestJS شعبية.
  2. مكتبة telegraf للتفاعل مع Telegram API.
  3. مكتبة node-vk-bot-api للتفاعل مع VK API.
  4. مكتبة Typeorm لتنظيم طبقة تخزين البيانات.
  5. اختبارات باستخدام المخاوي ومكتبة تشاي تؤكد .
  6. CI باستخدام Travis CI للاختبار وإجراءات GitHub لنشر صور عامل ميناء.

كعمل جانبي ، دعونا نحاول تكوين صداقات لدينا مع Viber ، مما يجعلها عالمية للاستخدام في العديد من خدمات المراسلة.


بالنسبة لأولئك الذين يريدون معرفة ما جاء منه ، مرحبا بكم في القط.


بيان المشكلة


كنشاط بدني مفيد ، أفضل كرة القدم. على مستوى الهواة ، بالطبع. كونك عضوًا في العديد من القنوات والدردشة في vk و viber و telegram ، فغالبًا ما يتعين عليك رؤية الصورة التالية:


صورة


ما نراه هنا:


  1. "P" أضاف نفسه.
  2. بعده ، تم إضافة 3 أشخاص آخرين ، من بينهم كان "C" معين.
  3. أضاف "P" رفيقه المسمى "Dimon".
  4. بعد ذلك ، لم يكن "P" كسولًا جدًا واحتسب أنه يوجد في الوقت الحالي ما يصل إلى 5 أشخاص.
  5. ومع ذلك ، لم تكن هذه المعلومات ذات صلة لفترة طويلة ، حيث قررت أيضًا الركض وإضافة نفسي.

في الحالات المهملة بشكل خاص ، يمكن للأشخاص وضع علامة "+" ثم إلغاء مشاركتهم أو دعوة الأصدقاء ليس من الدردشة أو التسجيل للمشاركين الآخرين في الدردشة والقيام بأنشطة أخرى. في النهاية ، يؤدي هذا إلى حقيقة أنه عندما يأتي الجميع في النهاية للعب ، اتضح أن:


  1. تعيين فاسيا +1 يعني ليس فقط نفسه ، ولكن أيضا صديقه غوش.
  2. كتب كوليا أنه سيأتي ، لكن المنظم سبيريدون نظر بعينيه فقط إيجابيات.

نتيجة لذلك ، ربما يكون لدينا عدد غير متساوٍ في عدد الفرق "غوش الإضافية" وبدا كوليا بشكل غير متوقع.


من أجل تجنب مثل هذه التصادمات ، كتبت روبوتًا بسيطًا يساعد على عرض مكونات المشاركين في اللعبة المرئية بشكل مرئي ويكون ملائمًا لإضافتها / حذفها (حذفها).


لذلك ، في عملي الأساسي ، في رأيي ، يجب أن يكون الروبوت قادرًا على:


  1. أن تكون جزءا لا يتجزأ من أي عدد من الدردشات. تخزين دولتك لكل دردشة على حدة.
  2. باستخدام الأمر / event_add ، أنشئ حدثًا نشطًا جديدًا يحتوي على معلومات معينة وقائمة فارغة من اللاعبين. يجب أن يصبح الحدث النشط السابق غير نشط.
  3. يجب أن يلغي الأمر / event_remove الحدث النشط حاليًا.
  4. يجب أن يضيف الأمر / add عضوًا جديدًا إلى الحدث النشط حاليًا. علاوة على ذلك ، إذا تم استدعاء الأمر دون أي نص إضافي ، فيجب إضافة الشخص الذي كتب الأمر. خلاف ذلك ، يتم إضافة شخص يتم تحديد اسمه عند استدعاء الأمر.
  5. يزيل الأمر / remove شخصًا من قائمة المشاركين وفقًا للقواعد الموضحة للأمر / add .
  6. يتيح لك الأمر / info معرفة من قام بالتسجيل بالفعل في اللعبة.
  7. من المرغوب فيه للغاية أن يستجيب الروبوت بنفس اللغة التي تم تكوين المشغل بها (أو باللغة الإنجليزية افتراضيًا).

لرؤية ما حدث في النهاية بسرعة ، يمكنك الانتقال على الفور إلى القسم الأخير "النتيجة والاستنتاجات". حسنًا ، إذا كنت مهتمًا بتفاصيل التنفيذ ، فسيتم وصف هذه المعلومات بتفاصيل كافية أدناه.


التصميم والتطوير


عند تصميم التطبيق ، ظهر المخطط التالي في رأسي:


صورة


هنا ، كانت الفكرة الرئيسية هي إحضار جميع تفاصيل العمل مع أنظمة المراسلة المختلفة إلى المحولات المناسبة وتغليف منطق التفاعل مع المكتبات التي تنفذ واجهات برمجة التطبيقات المقابلة ، ومعالجة البيانات وإحضارها في نموذج واحد.


نماذج واجهة ورسالة


بالنسبة للرسائل ، كان من الممكن تصميم واجهة IMessage التالية ، والتي ترضيها الفئات التي تخزن بيانات الرسائل الواردة للعديد من الأنظمة والأساليب للتعامل مع هذه البيانات:


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

تحتوي الفئة الأساسية BaseMessage التي تنفذ هذه الواجهة على النموذج التالي:


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

فئة الرسالة لـ 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); } } 

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

ما يجب الانتباه إليه:


  1. chatId هو المعرف الفريد للدردشة. بالنسبة لـ Telegram ، تأتي هذه الرسالة بوضوح في بنية كل رسالة. ومع ذلك ، في حالة VK لا يوجد مثل هذا المعرف بشكل صريح. كيف تكون؟ للإجابة على هذا السؤال ، تحتاج إلى فهم كيفية عمل الروبوت في VK. في هذا النظام ، تعمل روبوتات المراسلة الجماعية نيابة عن المجتمع الذي يبدأ من أجله. أي تحتوي كل رسالة بوت على معرف مجتمع group_id . بالإضافة إلى ذلك ، يأتي peer_id في الرسالة (يمكن قراءة المزيد من التفاصيل هنا ) كمعرف للمحادثة الجماعية في حالتنا. بناءً على هاتين المعرفتين ، يمكنك إنشاء معرف الدردشة الخاص بك ، على سبيل المثال على النحو التالي:


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

    هذه الطريقة هي بالتأكيد ليست مثالية ، ولكن تنطبق على المهمة الحالية.


  2. النص الكامل والنص . كما يشير اسم المتغيرات ، يحتوي النص الكامل على النص الكامل للرسالة ، بينما يحتوي النص فقط على الجزء الذي يأتي بعد اسم الأمر.



هذا مناسب لأنه يسمح لك باستخدام حقل النص الجاهز لتحليل المعلومات المساعدة الموجودة فيه ، مثل تاريخ الحدث أو اسم اللاعب.


  1. بخلاف Telegram ، لا تدعم رسائل VK مجموعة من العلامات لتمييز نص مثل <b> أو <i> ، لذلك ، عند الاستجابة ، يجب حذف هذه العلامات باستخدام التعبيرات المعتادة.

محولات لتلقي وإرسال الرسائل


بعد إنشاء الواجهة وبنية البيانات لرسائل Telegram و VK ، حان الوقت لتنفيذ الخدمات لمعالجة الأحداث الواردة.


صورة


يجب أن تقوم هذه الخدمات بتهيئة الوحدات التابعة لجهات خارجية للتفاعل مع واجهات برمجة التطبيقات Telegram و VK وإطلاق آليات الاقتراع الطويل ، بالإضافة إلى وجود اتصال بين الأوامر الواردة والأحداث الداخلية التي تحدث في النظام عند تلقي البيانات من خدمات المراسلة.


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

ما يجب الانتباه إليه:


  1. نظرًا لوجود بعض المشكلات في الوصول إلى خدمة Telegram (مرحبًا Roskomnadzor) ، كان من الضروري استخدام وكيلًا اختياريًا يوفر حزمة socks5-https-client .
  2. عند معالجة حدث ، يضاف حقل إلى السياق مع قيمة الأمر مع نص الرسالة التي تم استلامها.
  3. بالنسبة لـ VK ، يجب عليك تنزيل بيانات المستخدم الذي أرسل الرسالة بشكل منفصل باستخدام مكالمة API منفصلة:
     const [from] = await this.bot.execute('users.get', { user_ids: ctx.message.from_id, }); 

نموذج البيانات


لتنفيذ الوظيفة المعلنة للبوت ، كانت ثلاثة طرز كافية:


  1. Chat - الدردشة أو المحادثة.
  2. Event - حدث للدردشة المجدولة لتاريخ ووقت محددين.
  3. Player - مشارك أو لاعب مشارك في الحدث.

مخطط البيانات


تطبيقات النماذج باستخدام مكتبة typeorm هي:


دردشة
 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[]; } 

حدث
 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[]; } 

لاعب
 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; } 

هنا ، من الضروري أن نناقش مرة أخرى آلية الروبوت لمختلف الفرق من حيث التلاعب بنماذج الدردشة والحدث والمشغل.


  1. عند استلام أي أمر ، يتم التحقق من وجود محادثة في قاعدة البيانات مع chatId . إذا لم يكن هناك سجل مطابق ، فسيتم إنشاؤه.
  2. لتبسيط المنطق ، يمكن أن تحتوي كل محادثة على حدث نشط واحد فقط ( حدث ). أي عند استلام /event_add ، يتم إنشاء حدث نشط جديد في النظام مع التاريخ المحدد.
    الحدث النشط الحالي (إن وجد) يصبح غير نشط.
  3. /event_remove يعثر على الحدث النشط في الدردشة الحالية ويقوم بإلغاء تنشيطه.
  4. /info يعثر على حدث نشط في الدردشة الحالية ، ويعرض معلومات عن هذا الحدث وقائمة من اللاعبين ( لاعب ).
  5. /add و /remove العمل مع الحدث النشط إضافة وإزالة اللاعبين منه.

الخدمة المقابلة للعمل مع البيانات هي كما يلي:


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

استجابة القالب


بالإضافة إلى خدمة StorageService الموصوفة للعمل مع البيانات ، كانت هناك أيضًا حاجة إلى تنفيذ خدمة TemplateService مخصصة تتمثل مهامها في الوقت الحالي في:


  1. قم بتنزيل وتجميع قوالب المقاود للحصول على خيارات للإجابة لمختلف الأوامر بلغات مختلفة.
  2. اختيار قالب مناسب حسب الحدث الحالي وحالة الاستجابة ولغة المستخدم.
  3. تعبئة القالب بالبيانات التي تم الحصول عليها نتيجة للأمر.

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

ما يجب الانتباه إليه:


  1. توجد ملفات القوالب في دليل منفصل. / قوالب في جذر المشروع ويتم تنظيمها حسب اللغة والإجراء (الأمر) وحالة الاستجابة.


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

    عند تهيئة التطبيق ، يتم تحميل جميع قوالب الاستجابة في الذاكرة وتعبئة خريطة بها مفاتيح تحدد القالب بشكل فريد:


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

  2. يحتوي النظام حاليًا على مجموعتين من القوالب: للروسية والإنجليزية.
    يتم توفير احتياطي باللغة الافتراضية (الإنجليزية) في غياب القالب الضروري للغة الحدث الحالي.


  3. المقاود قوالب أنفسهم ، على سبيل المثال:


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

    يحتوي على كل من العناصر النائبة التقليدية ومجموعة من العلامات الصالحة لتنسيق الاستجابات في Telegram.



خدمات معالجة الأوامر (الإجراءات)


الآن وقد تم وصف خدمات الدعم الأساسية ، فقد حان الوقت للانتقال إلى تنفيذ منطق أعمال الروبوت نفسه. تخطيطيا العلاقة بين الوحدات المقدمة هنا:


الإجراءات


تتضمن مهام الفئة BaseAction الأساسية ما يلي:


  1. اشترك في حدث معين من AppEmitter .
  2. الوظيفة العامة لمعالجة الأحداث ، وهي:
    • البحث عن واحدة موجودة أو إنشاء دردشة جديدة في سياقها ستتم معالجة الفريق.
    • استدعاء إلى أسلوب قالب doAction تطبيقه فرديًا لكل فئة من فئات BaseAction الموروثة.
    • تطبيق القوالب على استجابة doAction الناتجة باستخدام TemplateService .

العمل الأساسي
 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); } } } 

تتمثل مهمة الفئات الفرعية BaseAction في تنفيذ طريقة doAction للمدخلات:


  1. المحادثة التي تم إنشاؤها أو تعريفها في الفئة الأساسية
  2. كائن يتوافق مع بروتوكول IMessage .

نتيجة لتنفيذ هذه الطريقة ، يتم إرجاع IMessage أيضًا ، ولكن مع الحالة المحددة لاختيار القالب الصحيح والبيانات التي ستشارك في قالب الاستجابة.


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

ما يجب الانتباه إليه:


  1. الأوامر /add و /remove تضيف / تزيل المشغل الذي يأتي اسمه بعد الأمر. إذا لم يتم تحديد الاسم ، فسيتم إضافة / إزالة اللاعب الذي اتصل بالفريق.
  2. استجابة لأوامر /add و /remove و /info ، يتم عرض قائمة محدّثة من مشغلات الحدث النشط.

تم نقل الوظيفة المطلوبة للتطبيق (1) و (2) إلى فئة مساعدة خاصة:


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

وضع كل ذلك معا


كما ذكر في بداية المقال ، يتم استخدام إطار عمل NestJS كإطار عمل للتطبيق. كنت محظوظًا في ذلك الوقت لحضور التقرير شخصيًا من خالق هذه المكتبة الرائعة.


غالبًا ما تخطئ العديد من قواعد البيانات والأدلة والمكتبات في NodeJS بعدم تقديم أي إستراتيجيات عقلانية للتهيئة والاتصال بين الوحدات النمطية. مع نمو التطبيق ، في غياب الاهتمام المناسب ، تصبح قاعدة الكود بمثابة خليط من المتطلبات بين الوحدات ، مما يؤدي في كثير من الأحيان إلى التبعيات الدورية أو العلاقات التي لا ينبغي أن تكون عليها من حيث المبدأ. Dependency Injection awilix , NestJS .


DI , NodeJS , , .




.


  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 . أي , . .
  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/ar483194/


All Articles