- PVSM.RU - https://www.pvsm.ru -
Всем привет. В этой статье я опишу своего чат-бота для сервиса обмена сообщениями telegram и социальной сети VK с использованием NodeJS.
На этом месте у многих читателей должно вырваться что-то вроде: "Доколе!" или "Что, опять ?!".
Да, похожие публикации уже были и на хабре в том числе. Но, тем не менее я считаю, что статья будет полезна. Кратко о том что с технической стороны представляет реализация бота:
В качестве побочного задания попробуем подружить нашего бота с Viber делая его таким образом универсальным для использования в нескольких сервисах обмена сообщениями.
Тем кто хочет узнать что из этого получилось добро пожаловать под кат.
В качестве полезной физической нагрузки я предпочитаю футбол. На любительском уровне разумеется. Являясь участником нескольких каналов и чатов в vk, viber и telegram часто приходится видеть такую картину:
Что мы здесь видим:
В особо запущенных случаях люди могут ставить "+" а потом отменять свое участие, приглашать друзей не из чата или отмечаться за других участников чата и делать прочие активности. В итоге это приводит тому, что когда все в итоге приезжают играть выясняется что:
В итоге имеем возможно неравные по кол-ву команды, "лишнего Гошу" и неожиданно возникшего Колю.
Для того, чтобы избежать таких коллизий я написал простого бота, который помогает наглядно отображать состав участников предстоящей игры и удобно в нее добавлять(ся)/удалять(ся).
Итак, в базовой реализации по моему мнению бот должен был уметь:
Чтобы быстро посмотреть что получилось в итоге, можно сразу перейти к последнему разделу "Результат и выводы". Ну а если интересуют детали реализации, то данная информация достаточно подробно изложена ниже.
При проектировании приложения в голове возникла примерно следующая схема:
Здесь основная идея состояла в том, чтобы вынести всю специфику работы с различными системами обмена сообщений в соответствующие адаптеры и инкапсулировать в них логику взаимодействия с библиотеками реализующими соответствующие API, обработку и приведение данных к единому виду.
Для сообщений получилось спроектировать следующий интерфейс 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, реализующий данный интерфейс имеет следующий вид:
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:
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:
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;
}
}
На что здесь стоит обратить внимание:
chatId — уникальный идентификатор чата. Для Telegram он приходит в явном виде в структуре каждого сообщения. Однако в случае VK такого идентификатора в явном виде нет. Как же быть? Чтобы ответить на этот вопрос нужно понять как бот функционирует в рамках VK. В этой системе бот в групповой переписке выступает от лица сообщества для которого он заводится. Т.е. каждое сообщение бота содержит числовой идентификатор сообщества group_id. Кроме того, в сообщении приходит peer_id (более подробно можно почитать здесь [7]) как идентификатор групповой беседы в нашем случае. На основании этих двух идентификатором можно построить свой идентификатор чата например таким образом:
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;
}
Данный способ конечно не совершенен, но применим для текущей задачи.
fullText и text. Как следует из названия переменных, fullText содержит полный текст сообщения, в то время как text содержит только ту часть, которая идет после названия команды.
Это удобно, так как позволяет использовать готовое поле text для парсинга содержащейся в нем вспомогательной информации вроде даты события или имени игрока.
<b>
или <i>
, поэтому при ответе данные тэги приходится удалять с помощью использования регулярных выражений.После создания интерфейса и структур данных для сообщений Telegram и VK пришло время для реализации сервисов обработки входящих событий.
Эти сервисы должны инициализировать сторонние модули для взаимодействия с API Telegram и VK запускать механизмы long-polling, а также иметь связь входящих команд и внутренних событий возникающих в системе при получении данных от сервисов обмена сообщениями.
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],
];
}
}
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],
];
}
}
На что здесь стоит обратить внимание:
const [from] = await this.bot.execute('users.get', {
user_ids: ctx.message.from_id,
});
Для реализации заявленной функциональности бота было достаточно наличия трех моделей:
Chat
— чат или беседа.Event
— событие для чате назначенное на определенную дату и время.Player
— участник или игрок принимающий участие в событии.Реализации моделей с помощью библиотеки typeorm [4] имеют вид:
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;
}
Здесь нужно еще раз проговорить механизм работы бота для различных команд с точки зрения манипуляции с моделями Chat, Event и Player.
chatId
. Если соответствующей записи нет, то она создается./event_add
в системе создается новое активное событие с указанной датой./event_remove
находит в текущем чате активное событие и деактивирует его./info
находит в текущем чате активное событие, выводит информацию по этому событию и список игроков (Player)./add
и /remove
работают с активным событием добавляя и удаляя из него игроков.Соответствующий сервис для работы с данными получился следующим:
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 задачами которого на данный момент являются:
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}`;
}
}
На что здесь стоит обратить внимание:
Файлы шаблонов лежат в отдельной директории ./templates
в корне проекта и структурированы по языку, действию (команде) и статусу ответа.
- templates
- en
- event_add
- invalid_date.hbs
- success.hbs
- event_info
- ru
При инициализации приложения все шаблоны ответов загружаются в память и заполняют Map с ключами которые однозначно идентифицируют шаблон:
private getTemplateKey(lang: string, action: string, status: string): string {
return `${lang}-${action}-${status}`;
}
В настоящий момент в системе есть 2 набора шаблонов: для русского и английского языков.
Предусмотрен fallback на дефолтный язык (английский) в случае отсутствия необходимого шаблона для языка текущего события.
Сами handlebars [9] шаблоны, например:
Player <strong>{{name}}</strong> will take part in the game
List of players:
{{#each players}}
{{index}}: <i>{{name}}</i>
{{/each}}
Total: <strong>{{total}}</strong>
содержат как традиционные placeholder-ы, так и набор допустимых тегов [10] для форматирования ответов в Telegram.
Теперь, когда описаны основные вспомогательные сервисы пришло время перейти к реализации собственно бизнес-логики бота. Схематично связь между модулями представлена здесь:
В задачи базового класса BaseAction входит:
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
принимающего на вход:
В результате выполнения данного метода также возвращается IMessage, но с установленным статусом для выбора правильного шаблона и данными которые будут участвовать в шаблонизации ответа.
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),
});
}
}
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);
}
}
}
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)),
});
}
}
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)),
});
}
}
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)),
});
}
}
На что здесь стоит обратить внимание:
/add
и /remove
добавляют/удаляют игрока чье имя идет после команды. Если имя не указано, то добавляется/удаляется игрок вызвавший команду./add
, /remove
и /info
выводится обновленный список игроков для активного события.Функциональность, необходимая для реализации (1) и (2) была вынесена в специальный вспомогательный класс:
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 [1]. Мне в свое время повезло лично присутствовать на докладе [11] от создателя данной замечательной библиотеки.
Многие бойлерплейты, руководства и библиотеки NodeJS часто грешат тем, что не предлагают никакой вменяемой стратегии инициализации и связей между модулями. С ростом приложения, при отсутствии должного внимания, кодовая база превращается в мешанину из require-ов между модулями, зачастую приводя даже к циклическим зависимостям или связям там, где их в принципе не должно быть. Данные проблемы можно решить используя например продвинутые Dependency Injection контейнеры вроде awilix [12], но NestJS [1] идет дальше.
Помимо собственного DI и средств для решения типичных задач, возникающих при разработке NodeJS сервиса, этот фреймворк предлагает по моему скромному мнению правильную парадигму формирования структуры модулей, сгруппированных вокруг тех или иных частей функциональности приложения.
Данный бот имеет следующие параметры конфигурации которые задаются с помощью переменных окружения.
TELEGRAM_BOT_TOKEN
— уникальный токен для Telegram бота.TELEGRAM_USE_PROXY
— флаг при включении которого задействуется прокси для доступа к TelegramAPI.TELEGRAM_PROXY_HOST
— хост прокси сервиса для доступа к TelegramAPI.TELEGRAM_PROXY_PORT
— порт прокси сервиса для доступа к TelegramAPI.TELEGRAM_PROXY_LOGIN
— логин для прокси сервиса доступа к TelegramAPI.TELEGRAM_PROXY_PASSWORD
— пароль для прокси сервиса доступа к TelegramAPI.VK_TOKEN
— уникальный токен для VK бота.DATABASE_URL
— строка подключения к БД. Используется для production окружения.На что здесь стоит обратить внимание:
TELEGRAM_BOT_TOKEN
для получения этого токена достаточно воспользоваться общей инструкцией [13] по созданию бота на стороне Telegram.VK_TOKEN
— как было упомянуто выше, бот VK действует от имени сообщества. Т.е. для получения данного токена, необходимо сначала создать или взять существующее сообщество и в рамках него настроить бота. Более подробно об этом можно почитать в соответствующем разделе [14] официальной документации.DATABASE_URL
— в production окружении для хранения данных используется PostgreSQL. В моем случае я просто подключился к неожиданно хорошему DBaaS под названием elephantsql [15] имеющему бесплатный план."Если что-то можно автоматизировать — это нужно автоматизировать". Я разделяю этот девиз, поэтому добавил традиционную интеграцию с замечательным TravisCI [16] для прогона тестов с помощью типичной конфигурации:
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 [17].
При реализации сбора Docker [18] образа и отправки его в Docker Hub [19] мне очень помогла отличная статься от пользователя Gonf [20] здесь на сайте Строим домашний CI/CD при помощи GitHub Actions и Python [21]. По аналогии с его решением я сделал свой Continuous Deployment, который запускается при публикации релиза в Github:
name: Release
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
env:
LOGIN: ${{ secrets.DOCKER_LOGIN }}
NAME: ${{ secrets.DOCKER_NAME }}
steps:
- uses: actions/checkout@v1
- name: Install Node.js
uses: actions/setup-node@v1
- name: Login to docker.io
run: echo ${{ secrets.DOCKER_PWD }} | docker login -u ${{ secrets.DOCKER_LOGIN }} --password-stdin
- name: npm install and build
run: npm ci && npm run build
- name: Build the Docker image
run: docker build -t $LOGIN/$NAME:${GITHUB_REF:10} -f Dockerfile .
- name: Push image to docker.io
run: docker push $LOGIN/$NAME:${GITHUB_REF:10}
Что же получилось в итоге. Для Telegram работа бота выглядит примерно так:
Создаем новое событие на определенную дату и время.
Игрок решил принять участие.
Игрок отменил свое участие в игре.
Похожая картина и для VK за исключением выделения текста тегами <b>
и <i>
.
Создаем новое событие на определенную дату и время.
Игрок отменил свое участие в игре.
Что касается Viber [22], то неожиданно этот месенжер оказался в силу определенных ограничений непригодным для подключения такого бота. С технической стороны проблем не возникло за исключением того, что интеграция с Viber [22] требует использования webhooks вместо long-polling. Настоящей проблемой оказалось то, что бот в Viber не может быть использован как полноправный участник групповой беседы. Про это в частности можно почитать здесь [23]. Поэтому в итоге от интеграции с этим мессенжером пришлось (временно?) отказаться.
В Telegram готового бота можно добавить к себе в групповой чат под именем @Tormozz48bot
.
Исходники моего проекта лежат на Github [24]. Приветствуются любые правки, замечания и предложения.
P.S. Спасибо всем за внимание.
Автор: Андрей Кузнецов
Источник [25]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/342504
Ссылки в тексте:
[1] NestJS: https://nestjs.com
[2] telegraf: https://www.npmjs.com/package/telegraf
[3] node-vk-bot-api: https://www.npmjs.com/package/node-vk-bot-api
[4] typeorm: https://www.npmjs.com/package/typeorm
[5] mocha: https://www.npmjs.com/package/mocha
[6] chai: https://www.npmjs.com/package/chai
[7] здесь: https://vk.com/dev/messages.send
[8] socks5-https-client: https://www.npmjs.com/package/socks5-https-client
[9] handlebars: https://handlebarsjs.com
[10] набор допустимых тегов: https://tlgrm.ru/docs/bots/api#formatting-options
[11] докладе: https://www.youtube.com/watch?v=jo-1EUxMmxc
[12] awilix: https://www.npmjs.com/package/awilix
[13] общей инструкцией: https://tlgrm.ru/docs/bots
[14] в соответствующем разделе: https://vk.com/dev/bots_docs
[15] elephantsql: https://www.elephantsql.com
[16] TravisCI: https://travis-ci.org
[17] codecov: https://codecov.io
[18] Docker: https://www.docker.com
[19] Docker Hub: https://hub.docker.com
[20] Gonf: https://habr.com/ru/users/Gonf/
[21] Строим домашний CI/CD при помощи GitHub Actions и Python: https://habr.com/ru/post/476368/
[22] Viber: https://www.viber.com
[23] здесь: https://github.com/Viber/sample-bot-isitup/issues/2
[24] на Github: https://github.com/tormozz48/football-chat-bot-2
[25] Источник: https://habr.com/ru/post/483194/?utm_source=habrahabr&utm_medium=rss&utm_campaign=483194
Нажмите здесь для печати.