initial commit

This commit is contained in:
Timofey Gelazoniya 2022-10-20 17:32:46 +03:00
commit 4673348f38
Signed by: zeldon
GPG Key ID: 047886915281DD2A
20 changed files with 3275 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
.env

7
etsc.config.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = {
esbuild: {
platform: 'node',
target: 'node16',
bundle: process.env.NODE_ENV === 'production' ? true : false
}
};

12
nodemon.json Normal file
View File

@ -0,0 +1,12 @@
{
"watch": [
"src"
],
"ignore": [
"src/**/*.test.ts",
"node_modules"
],
"ext": "ts,js,json",
"exec": "etsc && node ./dist/index.js",
"legacyWatch": true
}

2777
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "balabola-bot",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"scripts": {
"dev": "nodemon",
"build": "rm -rf ./dist && tsc --noEmit && NODE_ENV=production etsc",
"start": "node dist/index.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"dotenv": "^16.0.3",
"envalid": "^7.3.1",
"middleware-io": "^2.8.1",
"p-queue": "^6.6.2",
"pino": "^8.7.0",
"pino-pretty": "^9.1.1",
"undici": "^5.11.0",
"vk-io": "^4.7.0"
},
"devDependencies": {
"@types/node": "^18.11.2",
"esbuild": "^0.15.12",
"esbuild-node-tsc": "^2.0.4",
"nodemon": "^2.0.20",
"typescript": "^4.8.4"
}
}

27
src/balabola_api.ts Normal file
View File

@ -0,0 +1,27 @@
import { fetch } from 'undici';
interface BalabolaResponse {
bad_query: number,
query: string,
text: string,
error: number,
is_cached: number,
empty_zeliboba: number,
intro: number,
signature: string;
}
const BASE_URL = 'https://yandex.ru/lab/api/yalm/text3';
export async function balabola(query: string, intro: number): Promise<string> {
const result = await fetch(BASE_URL, {
method: 'POST',
body: JSON.stringify({
filter: 1,
intro,
query
})
}).then(res => res.json()) as BalabolaResponse;
return result.text;
}

9
src/bot.ts Normal file
View File

@ -0,0 +1,9 @@
import { VK } from "vk-io";
import { env } from "./env";
import handlers from "./handlers";
export const bot = new VK({
token: env.BOT_TOKEN,
});
bot.updates.on('message_new', handlers.compose());

164
src/composer.ts Normal file
View File

@ -0,0 +1,164 @@
import { BranchMiddlewareCondition, Middleware, MiddlewareReturn, NextMiddleware, UnknownObject } from 'middleware-io';
import { Composer as BaseComposer, MessageContext } from 'vk-io';
declare module 'middleware-io' {
class Composer<T extends UnknownObject, R = T> {
filter<V = UnknownObject>(condition: BranchMiddlewareCondition<T & V>, filterMiddleware: Middleware<T & V>): this;
}
}
type AllowArray<T> = T | T[];
type HearFunctionCondition<T, V> = (value: V, context: T) => boolean;
type HearCondition<T, V> = HearFunctionCondition<T, V> | RegExp | string | number | boolean;
type HearObjectCondition<T extends Record<string, any>> =
Record<string, AllowArray<HearCondition<T, any>>>
& {
[P in keyof T]?: AllowArray<HearCondition<T, T[P]>>;
};
type HearConditions<T extends Record<string, any>> = (
AllowArray<HearCondition<T, string | undefined>>
| AllowArray<HearObjectCondition<T>>
);
function splitPath(path: string): string[] {
return (
path
.replace(/\[([^[\]]*)\]/g, '.$1.')
.split('.')
.filter(Boolean)
);
}
function getObjectValue(source: Record<string, any>, selectors: string[]): any {
let link = source;
for (const selector of selectors) {
if (!link[selector]) {
return undefined;
}
link = link[selector];
}
return link;
}
function unifyCondition(condition: unknown): Function {
if (typeof condition === 'function') {
return condition;
}
if (condition instanceof RegExp) {
return (text: string | undefined): boolean => (
condition.test(text!)
);
}
if (Array.isArray(condition)) {
const arrayConditions = condition.map(unifyCondition);
return (value: string | undefined): boolean => (
Array.isArray(value)
? arrayConditions.every((cond): boolean => (
value.some((val): boolean => cond(val))
))
: arrayConditions.some((cond): boolean => (
cond(value)
))
);
}
return (value: string | undefined): boolean => value === condition;
}
export class Composer<C extends MessageContext, R = C> extends BaseComposer<C, R> {
constructor() {
super();
}
public hear<T = {}>(
hearConditions: HearConditions<C & T>,
handler: Middleware<C & T>
): this {
const rawConditions = !Array.isArray(hearConditions)
? [hearConditions]
: hearConditions;
const hasConditions = rawConditions.every(Boolean);
if (!hasConditions) {
throw new Error('Condition should be not empty');
}
if (typeof handler !== 'function') {
throw new TypeError('Handler must be a function');
}
let textCondition = false;
let functionCondtion = false;
const conditions = rawConditions.map((condition): Function => {
if (typeof condition === 'object' && !(condition instanceof RegExp)) {
functionCondtion = true;
const entries = Object.entries(condition).map(([path, value]): [string[], Function] => (
[splitPath(path), unifyCondition(value)]
));
return (text: string | undefined, context: C): boolean => (
entries.every(([selectors, callback]): boolean => {
const value = getObjectValue(context, selectors);
return callback(value, context);
})
);
}
if (typeof condition === 'function') {
functionCondtion = true;
return condition;
}
textCondition = true;
if (condition instanceof RegExp) {
return (text: string | undefined, context: C): boolean => {
const passed = condition.test(text!);
if (passed) {
context.$match = text!.match(condition)!;
}
return passed;
};
}
const stringCondition = String(condition);
return (text: string | undefined): boolean => text === stringCondition;
});
const needText = textCondition && functionCondtion === false;
this.use((context: C & T, next: NextMiddleware): MiddlewareReturn => {
const { text } = context;
if (needText && text === undefined) {
return next();
}
const hasSome = conditions.some((condition): boolean => (
condition(text, context)
));
return hasSome
? handler(context, next)
: next();
});
return this;
}
}

10
src/env.ts Normal file
View File

@ -0,0 +1,10 @@
import 'dotenv/config';
import { cleanEnv, str } from 'envalid';
export const env = cleanEnv(process.env, {
NODE_ENV: str({ choices: ["development", "production"] }),
LOG_LEVEL: str({
choices: ["trace", "debug", "info", "warn", "error", "fatal", "silent"],
}),
BOT_TOKEN: str(),
});

13
src/filters.ts Normal file
View File

@ -0,0 +1,13 @@
import { MessageContext } from "vk-io";
type MaybePromise<T> = T | Promise<T>;
type Predicate<C extends MessageContext> = (ctx: C) => MaybePromise<boolean>;
export const not = <C extends MessageContext>(predicate: Predicate<C>) => (ctx: C) =>
Promise.resolve(predicate(ctx)).then((v) => !v);
export const isChat = <C extends MessageContext>(ctx: C) =>
ctx.isChat === true;
export const isHasText = <C extends MessageContext>(ctx: C) =>
ctx.text ? true : false;

58
src/handlers/balabola.ts Normal file
View File

@ -0,0 +1,58 @@
import { Keyboard, MessageContext } from "vk-io";
import { balabola } from "../balabola_api";
import { Composer } from "../composer";
import { isChat, isHasText, not } from "../filters";
import { balabolaQueue } from "../queue";
export const composer = new Composer<MessageContext>();
const filter = composer
.filter(not(isChat), composer.compose())
.filter(!!not(isHasText), composer.compose());
const selectStyleKeyboard = <C extends MessageContext>(ctx: C) => {
return Keyboard.builder()
.textButton({ label: '1', payload: { command: 'пр 0 ' + ctx.text! } })
.textButton({ label: '2', payload: { command: 'пр 24 ' + ctx.text! } })
.textButton({ label: '3', payload: { command: 'пр 25 ' + ctx.text! } })
.textButton({ label: '4', payload: { command: 'пр 11 ' + ctx.text! } })
.row()
.textButton({ label: '5', payload: { command: 'пр 6 ' + ctx.text! } })
.textButton({ label: '6', payload: { command: 'пр 8 ' + ctx.text! } })
.textButton({ label: '7', payload: { command: 'пр 9 ' + ctx.text! } })
.row()
.textButton({ label: 'чзх', payload: { command: 'help' } })
.inline();
};
const balabolaMessage = `Выбери стилизацию ответа:
1: без стилизации
2: инструкция
3: рецепт
4: мудрость
5: история
6: википедия
7: синопсис
более подробно команда help`;
filter.hear(/^(?:продолжи)\s(.*)?$/i, async ctx => {
await ctx.send(balabolaMessage, {
keyboard: selectStyleKeyboard(ctx)
});
});
filter.hear(/^(?:пр)\s(0|24|25|11|6|8|9)\s(.*)?$/i, async ctx => {
const intro = ctx.$match[1];
const query = ctx.$match[2];
await ctx.send('генерирую!!');
const result = await balabolaQueue.add(() => balabola(query, +intro));
await ctx.send(result);
});
filter.use(async ctx => {
await ctx.send('генерирую!!');
const result = await balabolaQueue.add(() => balabola(ctx.text!, 6));
await ctx.send(result);
});

27
src/handlers/error.ts Normal file
View File

@ -0,0 +1,27 @@
import { APIError, MessageContext } from "vk-io";
import { Composer } from "../composer";
import { logger } from "../logger";
export const composer = new Composer<MessageContext>();
composer.use(async (ctx, next) => {
try {
await next();
} catch (err) {
logger.error(err);
}
});
composer.use(async (ctx, next) => {
try {
await next();
} catch (err) {
if (err instanceof APIError && err.code === 917) {
logger.error('no access to the chat');
await ctx.send('хачю доступ к чату :=(');
return;
}
throw err;
}
});

27
src/handlers/help.ts Normal file
View File

@ -0,0 +1,27 @@
import { Composer } from "../composer";
import { MessageContext } from "vk-io";
export const composer = new Composer<MessageContext>();
const helpMessage = `эта штука работает через балабола (https://yandex.ru/lab/yalm)
настройки стилизации:
1: без стиля
- ну тут все и так понятно
2: инструкции по применению
- Перечислите несколько предметов, а Балабоба придумает, как их использовать
3: рецепты
- Перечислите съедобные ингредиенты, а Балабоба придумает рецепт с ними
4: мудрости
- Напишите что-нибудь и получите народную мудрость
5: истории
- Начните писать историю, а Балабобы продолжит иногда страшно, но чаще смешно
6: википедия
- Напишите какое-нибудь слово, а Балабоба даст этому определение
7: синопсис
- Напишите название фильма (существующего или нет), а Балабоба расскажет вам, о чем он`;
composer.hear(/^(?:start|помощь|старт|начать|help)$/i, async ctx => {
await ctx.send(helpMessage, {
dont_parse_links: true
});
});

14
src/handlers/index.ts Normal file
View File

@ -0,0 +1,14 @@
import { Composer } from "../composer";
import { MessageContext } from "vk-io";
import { composer as updatesHandler } from "./updates";
import { composer as helpHandler } from "./help";
import { composer as balabolaHandler } from "./balabola";
import { composer as errorHandler } from "./error";
const handlers = new Composer<MessageContext>();
export default handlers;
handlers.use(errorHandler.compose());
handlers.use(updatesHandler.compose());
handlers.use(helpHandler.compose());
handlers.use(balabolaHandler.compose());

25
src/handlers/updates.ts Normal file
View File

@ -0,0 +1,25 @@
import { MessageContext } from "vk-io";
import { Composer } from "../composer";
import { isHasText, not } from "../filters";
import { logger } from "../logger";
export const composer = new Composer<MessageContext>();
const filter = composer.filter(!!not(isHasText), composer.compose());
filter.use(async (ctx, next) => {
const { messagePayload } = ctx;
ctx.state.command = messagePayload && messagePayload.command
? messagePayload.command
: null;
logger.debug({
payload: messagePayload,
state: ctx.state,
text: ctx.text
});
if (ctx.state.command) ctx.text = ctx.state.command;
return next();
});

4
src/index.ts Normal file
View File

@ -0,0 +1,4 @@
import { bot } from "./bot";
import { logger } from "./logger";
bot.updates.start().catch(logger.error).then(() => logger.info('bot started'));

21
src/logger.ts Normal file
View File

@ -0,0 +1,21 @@
import pino, { LoggerOptions } from "pino";
import PinoPretty, { PrettyOptions } from "pino-pretty";
import { env } from "./env";
const options: LoggerOptions = {
level: env.LOG_LEVEL
};
const prettyOptions: PrettyOptions = {
ignore: 'pid,hostname',
colorize: env.isDev ? true : false,
translateTime: 'SYS:dd-mm-yyyy, HH:MM:ss'
};
export let rawLogger = pino(options);
if (env.isDev) {
rawLogger = pino(options, PinoPretty(prettyOptions));
}
export const logger = rawLogger.child({ name: 'balabola' });

11
src/queue.ts Normal file
View File

@ -0,0 +1,11 @@
import PQueue from 'p-queue';
import { logger } from './logger';
export const balabolaQueue = new PQueue({
concurrency: 1,
interval: 2000,
intervalCap: 1,
carryoverConcurrencyCount: true
});
balabolaQueue.on('add', () => logger.info('new balabola job added to queue'));

5
src/utilities.ts Normal file
View File

@ -0,0 +1,5 @@
function isNumber(value: string | number): boolean {
return ((value != null) &&
(value !== '') &&
!isNaN(Number(value.toString())));
}

30
tsconfig.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"removeComments": true,
"resolveJsonModule": true,
"typeRoots": [
"./node_modules/@types"
],
"sourceMap": false,
"outDir": "dist",
"strict": true,
"lib": [
"esnext"
],
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "Node",
"skipLibCheck": true,
},
"include": [
"src/**/*",
"lib/**/*"
],
"exclude": [
"node_modules"
],
}