initial commit
This commit is contained in:
commit
4673348f38
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
7
etsc.config.js
Normal file
7
etsc.config.js
Normal 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
12
nodemon.json
Normal 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
2777
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal 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
27
src/balabola_api.ts
Normal 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
9
src/bot.ts
Normal 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
164
src/composer.ts
Normal 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
10
src/env.ts
Normal 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
13
src/filters.ts
Normal 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
58
src/handlers/balabola.ts
Normal 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
27
src/handlers/error.ts
Normal 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
27
src/handlers/help.ts
Normal 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
14
src/handlers/index.ts
Normal 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
25
src/handlers/updates.ts
Normal 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
4
src/index.ts
Normal 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
21
src/logger.ts
Normal 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
11
src/queue.ts
Normal 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
5
src/utilities.ts
Normal file
@ -0,0 +1,5 @@
|
||||
function isNumber(value: string | number): boolean {
|
||||
return ((value != null) &&
|
||||
(value !== '') &&
|
||||
!isNaN(Number(value.toString())));
|
||||
}
|
30
tsconfig.json
Normal file
30
tsconfig.json
Normal 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"
|
||||
],
|
||||
}
|
Loading…
Reference in New Issue
Block a user