Skip to content

Commit 87939b9

Browse files
authored
Merge pull request #1 from Yuvasee/api-abstraction
v2.0.0
2 parents a5a0488 + 93b74e8 commit 87939b9

File tree

11 files changed

+197
-70
lines changed

11 files changed

+197
-70
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
node_modules
22
dist
33
package-lock.json
4+
*.tgz

jest.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
testEnvironment: 'node',
4+
};

package.json

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
{
22
"name": "@v9v/nodejs-bot-engine",
3-
"version": "1.0.0",
3+
"version": "2.0.0",
44
"description": "Node.js bot engine",
5-
"main": "dist/index.js",
5+
"main": "src/index.ts",
66
"scripts": {
7-
"prepublish": "npx typescript --declaration",
8-
"test": "echo \"Error: no test specified\" && exit 1"
7+
"build": "rimraf dist && npx typescript --declaration",
8+
"prepublish": "npm run build",
9+
"test": "jest --watch",
10+
"coverage": "jest --coverage"
911
},
1012
"author": "Yuri Vasilchikov",
1113
"license": "ISC",
1214
"devDependencies": {
1315
"@types/dotenv": "^8.2.0",
16+
"@types/jest": "^24.0.25",
1417
"@types/node": "^13.1.4",
1518
"@types/node-telegram-bot-api": "^0.40.1",
19+
"jest": "^24.9.0",
20+
"ts-jest": "^24.3.0",
1621
"tslint": "^5.20.1",
1722
"typescript": "^3.7.4"
1823
},

src/BotEngine.ts

Lines changed: 62 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,92 @@
1-
import TelegramBot = require('node-telegram-bot-api');
2-
import Command from './interfaces/Command';
3-
import CommandSet from './interfaces/CommandSet';
1+
import { IBotEngine, IBotApiAdapter, BotSpeechApi, Command, Feature, Messenger } from './interfaces';
2+
import * as re from './re';
3+
import TelegramApiAdapter from './adapters/TelegramApiAdapter';
44

5-
export default class BotEngine {
6-
private registeredCommands = new Set<string>([]);
5+
const ADAPTER_MAP: Record<Messenger, any> = {
6+
[Messenger.Telegram]: TelegramApiAdapter,
7+
}
78

8-
constructor(private api: TelegramBot) {}
9+
export default class BotEngine implements IBotEngine {
10+
private commandsRegistry = new Set<string>([]);
11+
private apiAdapter: IBotApiAdapter;
912

10-
public registerCommand(this: BotEngine, command: Command) {
11-
if (this.registeredCommands.has(command.name)) {
12-
throw new Error(`Command names collision occured: ${command.name}. Commands should have unique names.`);
13-
}
13+
constructor(messenger: Messenger, secret: string) {
14+
this.apiAdapter = new ADAPTER_MAP[messenger](secret);
15+
}
1416

15-
if (!command.trigger) {
16-
throw new Error(`Command ${command.name} has no trigger`);
17+
public registerCommand(command: Command) {
18+
const validationError = this.validateCommand(command);
19+
if (validationError) {
20+
throw new Error(validationError);
1721
}
1822

19-
this.api.onText(command.trigger, command.reaction(this.api));
20-
this.registeredCommands.add(command.name);
23+
const { triggers, reaction, name } = command;
24+
triggers.forEach((trigger) => this.apiAdapter.onText(trigger, reaction));
25+
this.commandsRegistry.add(name);
26+
console.log('Registered command: ' + name);
2127

22-
console.log('Registered command: ' + command.name);
2328
return this;
2429
}
2530

26-
public registerCommandSet(this: BotEngine, commandSet: CommandSet) {
27-
const { name: setName, commands, fallback } = commandSet;
28-
const commandNames = Object.keys(commands);
29-
30-
if (this.registeredCommands.has(setName)) {
31-
throw new Error(`Command names collision occured: ${setName}. Commands should have unique names.`);
31+
public registerFeature(feature: Feature) {
32+
const validationResult = this.validateFeature(feature);
33+
if (validationResult) {
34+
throw new Error(validationResult);
3235
}
33-
this.registeredCommands.add(setName);
3436

35-
console.log(`Command set: ${setName} ---`);
37+
const { name, commands, fallback } = feature;
38+
const commandNames = Object.keys(commands);
39+
40+
this.commandsRegistry.add(name);
41+
console.log(`Command set: ${name} ---`);
3642

37-
commandNames.forEach(commandName => {
38-
// matches "setName commandName..."
39-
const reKnownCmd = `^${setName}[ \\t]+${commandName}([ \\t]+.*)?$`;
40-
commands[commandName].trigger = new RegExp(reKnownCmd);
43+
commandNames.forEach((commandName) => {
44+
commands[commandName].triggers = commands[commandName].triggers || [];
45+
commands[commandName].triggers.push(re.featureCommand(name, commandName));
4146
this.registerCommand(commands[commandName]);
4247
});
4348

44-
// matches "setName" and "setName not(one|of|commandNames)..."
45-
const reFallbackCmd = `^${setName}(?!\\S|([ \\t]+)?(${commandNames.join('|')}))`;
46-
fallback.trigger = new RegExp(reFallbackCmd);
49+
fallback.triggers = fallback.triggers || [];
50+
fallback.triggers.push(re.featureFallback(name, commandNames));
4751
this.registerCommand(fallback);
4852

4953
console.log(`---`);
5054

5155
return this;
5256
}
5357

54-
public registerFallback(this: BotEngine) {
55-
// matches minus command
56-
const reMinusSum = '-\\d+(?:[.,]\\d+)?(?:[+\\-*\\/]\\d+(?:[.,]\\d+)?)*';
57-
const commands = [...this.registeredCommands];
58-
const commandsJoined = commands.join('|');
58+
public registerFallback() {
59+
const commandNames = [...this.commandsRegistry];
60+
61+
const triggers = [re.globalFallback(commandNames)];
62+
const reaction = (bot: BotSpeechApi) => {
63+
bot.sendMessage(`commands:\n${commandNames.join('\n')}`);
64+
};
65+
66+
this.registerCommand({ name: 'global fallback', triggers, reaction });
67+
}
5968

60-
// matches all but valid commands
61-
const reFallbackGlobal = `^(?!(${reMinusSum}|${commandsJoined})).+|^(${commandsJoined})\\w+.*`;
69+
private validateCommand(command: Command): string | undefined {
70+
if (this.commandsRegistry.has(command.name)) {
71+
return `Command names collision occured: ${command.name}. Commands should have unique names.`;
72+
}
6273

63-
this.registerCommand({
64-
name: 'global fallback',
74+
if (!command.triggers?.length) {
75+
return `Command ${command.name} has no triggers`;
76+
}
6577

66-
trigger: new RegExp(reFallbackGlobal),
78+
if (command.triggers.map((t) => t instanceof RegExp).includes(false)) {
79+
return `Command ${command.name} triggers array contains invalid element`;
80+
}
81+
}
6782

68-
reaction: botApi => (msg, match) => {
69-
botApi.sendMessage(msg.chat.id, `commands: \n${commands.join('\n')}`);
70-
},
71-
});
83+
private validateFeature(feature: Feature): string | undefined {
84+
if (this.commandsRegistry.has(feature.name)) {
85+
return `Command names collision occured: ${name}. Commands should have unique names.`;
86+
}
7287

73-
return this;
88+
if (!Object.keys(feature.commands).length) {
89+
return `Feature ${feature.name} has no commands`;
90+
}
7491
}
7592
}

src/adapters/TelegramApiAdapter.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import TelegramBot = require('node-telegram-bot-api');
2+
import { IBotApiAdapter, BotSpeechApi, CommonMessage, Messenger, SheetData, Reaction } from '../interfaces';
3+
import { ParseMode } from 'node-telegram-bot-api';
4+
5+
export default class TelegramApiAdapter implements IBotApiAdapter {
6+
private api: TelegramBot;
7+
8+
constructor(secret: string) {
9+
this.api = new TelegramBot(secret, { polling: true });
10+
}
11+
12+
public onText(this: TelegramApiAdapter, trigger: RegExp, reaction: Reaction) {
13+
this.api.onText(trigger, (rawMessage: TelegramBot.Message) => {
14+
const speechApi = this.makeSpeechApi(rawMessage);
15+
const message = this.makeCommonMessage(rawMessage);
16+
reaction(speechApi, message);
17+
});
18+
}
19+
20+
private makeSpeechApi(this: TelegramApiAdapter, msg: TelegramBot.Message): BotSpeechApi {
21+
const telegramApi = this.api;
22+
return {
23+
sendMessage(text: string) {
24+
telegramApi.sendMessage(msg.chat.id, text, { parse_mode: 'HTML' as ParseMode });
25+
},
26+
sendFile(file: Buffer, fileName: string, contentType: string) {
27+
telegramApi.sendDocument(msg.chat.id, file, {}, { filename: fileName, contentType });
28+
},
29+
};
30+
}
31+
32+
private makeCommonMessage(rawMessage: TelegramBot.Message) {
33+
return {
34+
messenger: Messenger.Telegram,
35+
text: rawMessage.text,
36+
userId: rawMessage.from.id.toString(),
37+
} as CommonMessage;
38+
}
39+
}

src/index.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import BotEngine from './BotEngine';
2-
import Command from './interfaces/Command';
3-
import CommandSet from './interfaces/CommandSet';
2+
export * from './interfaces';
43

54
export default BotEngine;
6-
export { BotEngine, Command, CommandSet };

src/interfaces/Command.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.

src/interfaces/CommandSet.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/interfaces/index.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
export interface IBotEngine {
2+
registerCommand: (command: Command) => void;
3+
registerFeature: (feature: Feature) => void;
4+
registerFallback: () => void;
5+
}
6+
7+
export interface IBotApiAdapter {
8+
onText: (trigger: RegExp, reaction: Reaction) => void;
9+
}
10+
11+
export interface BotConfig {
12+
telegramToken: string;
13+
}
14+
15+
/**
16+
* On every message received new BotApi instance created
17+
* with bindings to proper channel API, chat id etc.
18+
*/
19+
export interface BotSpeechApi {
20+
sendMessage: (text: string) => void;
21+
sendFile: (file: Buffer, fileName: string, contentType: string) => void;
22+
}
23+
24+
export enum Messenger {
25+
Telegram = 'Telegram',
26+
}
27+
28+
export interface CommonMessage {
29+
messenger: Messenger;
30+
text: string;
31+
userId: string;
32+
}
33+
34+
export interface SheetData {
35+
columns: string[];
36+
rows: string[][];
37+
}
38+
39+
export type Reaction = (bot: BotSpeechApi, msg: CommonMessage) => void;
40+
41+
export interface Command {
42+
name: string;
43+
triggers?: RegExp[];
44+
reaction: Reaction;
45+
}
46+
47+
export interface Feature {
48+
name: string;
49+
commands: Record<string, Command>;
50+
fallback: Command;
51+
}

src/re.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Matches "feature command [anything...]"
3+
* Used to match particular feature command
4+
* @param {string} feature
5+
* @param {string} command
6+
*/
7+
export function featureCommand(feature: string, command: string): RegExp {
8+
return new RegExp(`^${feature}[ \\t]+${command}([ \\t]+.*)?$`);
9+
}
10+
11+
/**
12+
* Matches "feature" and "feature [anything but (one|of|commands)]"
13+
* Used to match improper feature use and show fallback
14+
* @param {string} feature
15+
* @param {string[]} commands
16+
*/
17+
export function featureFallback(feature: string, commands: string[]): RegExp {
18+
return new RegExp(`^${feature}(?!\\S|([ \\t]+)?(${commands.join('|')}))`);
19+
}
20+
21+
/**
22+
* Matches everything but valid commands
23+
* @param {string[]} commands
24+
*/
25+
export function globalFallback(commands: string[]): RegExp {
26+
const reMinusSum = '-\\d+(?:[.,]\\d+)?(?:[+\\-*\\/]\\d+(?:[.,]\\d+)?)*';
27+
const joinedCommands = commands.join('|');
28+
return new RegExp(`^(?!(${reMinusSum}|${joinedCommands})).+|^(${joinedCommands})\\w+.*`);
29+
}

0 commit comments

Comments
 (0)