refactor!: rewrite bot core; add AI (Ollama, Gemini), DB, new commands

This commit is contained in:
2026-01-12 15:32:50 +03:00
parent 9d74ad9861
commit df9471a7e4
137 changed files with 11341 additions and 2025 deletions
+42
View File
@@ -0,0 +1,42 @@
/* eslint-disable no-unused-vars */
import {CallbackQuery, InlineKeyboardButton} from "typescript-telegram-bot-api";
import {Requirements} from "./requirements";
import {bot} from "../index";
export abstract class CallbackCommand {
abstract text: string;
abstract data: string;
requirements?: Requirements = null;
abstract execute(query: CallbackQuery): Promise<void>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
afterExecute(query: CallbackQuery): Promise<void> {
return Promise.resolve();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected getOptions(query: CallbackQuery): AnswerCallbackQueryOptions {
return {callback_query_id: query.id};
}
async answerCallbackQuery(query: CallbackQuery): Promise<void> {
bot.answerCallbackQuery(this.getOptions(query)).catch(console.error);
}
asButton(): InlineKeyboardButton {
return {
text: this.text,
callback_data: this.data
};
}
}
export interface AnswerCallbackQueryOptions {
callback_query_id: string;
text?: string;
show_alert?: boolean;
url?: string;
cache_time?: number;
}
+15
View File
@@ -0,0 +1,15 @@
import {Message} from "typescript-telegram-bot-api";
import {Requirements} from "./requirements";
export abstract class ChatCommand {
abstract regexp: RegExp;
requirements?: Requirements = null;
title?: string;
description?: string;
abstract execute(
msg: Message,
match?: RegExpExecArray
): Promise<void>;
}
+9
View File
@@ -0,0 +1,9 @@
export abstract class Dao<I> {
abstract getAll(): Promise<I[]>;
abstract getById(params: never): Promise<I | null>
abstract getByIds(params: never): Promise<I[]>
abstract insert(items: never[]): Promise<true>
}
+7
View File
@@ -0,0 +1,7 @@
export enum Requirement {
BOT_CREATOR,
BOT_ADMIN,
BOT_CHAT_ADMIN,
CHAT,
REPLY
}
+33
View File
@@ -0,0 +1,33 @@
import {Requirement} from "./requirement";
export class Requirements {
requirements: Requirement[] = [];
private constructor(requirements?: Requirement[]) {
this.requirements = requirements;
}
static Build(...requirements: Requirement[]): Requirements {
return new Requirements(requirements);
}
isRequiresBotCreator(): boolean {
return this.requirements.includes(Requirement.BOT_CREATOR);
}
isRequiresBotAdmin(): boolean {
return this.requirements.includes(Requirement.BOT_ADMIN);
}
isRequiresBotChatAdmin(): boolean {
return this.requirements.includes(Requirement.BOT_CHAT_ADMIN);
}
isRequiresChat(): boolean {
return this.requirements.includes(Requirement.CHAT);
}
isRequiresReply(): boolean {
return this.requirements.includes(Requirement.REPLY);
}
}
+42
View File
@@ -0,0 +1,42 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {fullName, logError, oldSendMessage} from "../util/utils";
import {Environment} from "../common/environment";
import {botUser} from "../index";
export class AdminsAdd extends ChatCommand {
regexp = /^\/addadmin/i;
title = "/addAdmin";
description = "Add user to admins";
requirements = Requirements.Build(
Requirement.BOT_CREATOR,
Requirement.REPLY,
Requirement.CHAT
);
async execute(msg: Message): Promise<void> {
if (!msg.reply_to_message) return;
const id = msg.reply_to_message.from.id;
const text = fullName(msg.reply_to_message.from);
if (id === botUser.id) {
await oldSendMessage(msg, "Бот не может сам себя сделать админом").catch(logError);
return;
}
if (id === Environment.CREATOR_ID) {
await oldSendMessage(msg, "Создатель бота и так является админом").catch(logError);
return;
}
if (await Environment.addAdmin(id)) {
await oldSendMessage(msg, text + " теперь админ!").catch(logError);
} else {
await oldSendMessage(msg, text + " и так уже админ 🤔").catch(logError);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {fullName, logError, oldSendMessage} from "../util/utils";
import {Environment} from "../common/environment";
import {botUser} from "../index";
export class AdminsRemove extends ChatCommand {
regexp = /^\/removeadmin/i;
title = "/removeAdmin";
description = "Remove user from admins";
requirements = Requirements.Build(
Requirement.BOT_ADMIN,
Requirement.REPLY,
Requirement.CHAT,
);
async execute(msg: Message): Promise<void> {
if (!msg.reply_to_message) return;
const id = msg.reply_to_message.from.id;
const text = fullName(msg.reply_to_message.from);
if (id === botUser.id) {
await oldSendMessage(msg, "Бот не может сам себя убрать из админов").catch(logError);
return;
}
if (id === Environment.CREATOR_ID) {
await oldSendMessage(msg, "Создатель бота не может перестать быть админом").catch(logError);
return;
}
if (await Environment.removeAdmin(id)) {
await oldSendMessage(msg, text + " больше не админ!").catch(logError);
} else {
await oldSendMessage(msg, text + " и так не был админом 🤔").catch(logError);
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {errorPlaceholder, logError, oldSendMessage} from "../util/utils";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
export class Ae implements ChatCommand {
regexp = /^\/ae\s([^]+)/i;
title = "/ae";
description = "evaluation";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message, params: string[]) {
const match = params[1];
try {
let e = eval(match);
e = ((typeof e == "string") ? e : JSON.stringify(e));
await oldSendMessage(msg, e).catch(async () => await errorPlaceholder(msg));
} catch (e) {
const text = e.message.toString();
if (text.includes("is not defined")) {
await oldSendMessage(msg, "variable is not defined").catch(logError);
return;
}
console.error(`${text}
* Stacktrace: ${e.stack}`);
await oldSendMessage(msg, text).catch(logError);
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import {ChatCommand} from "../base/chat-command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {bot, botUser} from "../index";
import {fullName, logError, oldSendMessage, replyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
export class Ban extends ChatCommand {
regexp = /^\/ban/i;
title = "/ban [reply]";
description = "ban user from chat";
requirements = Requirements.Build(
Requirement.CHAT,
Requirement.BOT_CHAT_ADMIN,
Requirement.REPLY,
Requirement.BOT_ADMIN
);
async execute(msg: Message) {
if (!msg.reply_to_message) return;
const user = msg.reply_to_message.from;
const userId = user.id;
if (userId === botUser.id) {
await replyToMessage(msg, "Используй /leave").catch(logError);
return;
}
if (userId === Environment.CREATOR_ID) {
await replyToMessage(msg, "Бот не будет банить своего создателя.").catch(logError);
return;
}
if (msg.from.id !== Environment.CREATOR_ID && Environment.ADMIN_IDS.has(userId)) {
await replyToMessage(msg, "Бот не будет банить своих администраторов.").catch(logError);
return;
}
bot.banChatMember({chat_id: msg.chat.id, user_id: userId})
.then(async () => {
await oldSendMessage(msg, `${fullName(user)} забанен 🚫`).catch(logError);
})
.catch(async () => {
await oldSendMessage(msg, `Не смог забанить ${fullName(user)} ☹️`).catch(logError);
});
}
}
+18
View File
@@ -0,0 +1,18 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {MessageStore} from "../common/message-store";
import {logError, sendMessage} from "../util/utils";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
export class CacheClear extends ChatCommand {
regexp = /^\/clearcache$/i;
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message): Promise<void> {
const size = MessageStore.all().size;
MessageStore.clear();
await sendMessage({chatId: msg.chat.id, text: `Было удалено сообщений: ${size}`}).catch(logError);
}
}
+17
View File
@@ -0,0 +1,17 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {logError, sendMessage} from "../util/utils";
import {MessageStore} from "../common/message-store";
export class CacheSize extends ChatCommand {
regexp = /^\/cachesize$/i;
async execute(msg: Message): Promise<void> {
const cacheSize = MessageStore.all();
await sendMessage({
chatId: msg.chat.id,
text: `Количество сохранённых сообщений: ${cacheSize.size}`
}).catch(logError);
}
}
+37
View File
@@ -0,0 +1,37 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {logError, randomValue, replyToMessage} from "../util/utils";
export class Choice extends ChatCommand {
regexp = /^\/choice\b\s*(.*)$/i;
title = "/choice a, b, ..., c";
description = "Выбор случайного значения";
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
console.log("match", match);
const payload = match[1];
const re =
/\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^,]+?))\s*(?:,|$)/g;
const out: string[] = [];
for (const mm of payload.matchAll(re)) {
const raw = (mm[1] ?? mm[2] ?? mm[3] ?? "").trim();
const val = raw
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t")
.replace(/\\"/g, "\"")
.replace(/\\'/g, "'")
.replace(/\\\\/g, "\\");
if (val.length) out.push(val);
}
const random = randomValue(out);
await replyToMessage(msg, `Выбрал *${random}*`, "Markdown").catch(logError);
}
}
+14
View File
@@ -0,0 +1,14 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {getRangedRandomInt, logError, replyToMessage} from "../util/utils";
export class Coin extends ChatCommand {
regexp = /^\/coin$/i;
title = "/coin";
description = "Heads or tails";
async execute(msg: Message): Promise<void> {
const random = getRangedRandomInt(0, 2);
const headsOrTails = random === 1 ? "Выпал *Орёл* 🪙" : "Выпала *Решка* 🪙";
await replyToMessage(msg, headsOrTails, "Markdown").catch(logError); }
}
+28
View File
@@ -0,0 +1,28 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {logError, randomValue} from "../util/utils";
import {bot} from "../index";
type DiceEmoji = "🎲" | "🎯" | "🏀" | "⚽" | "🎳" | "🎰";
const emojis = ["🎲", "🎯", "🏀", "⚽", "🎳", "🎰"];
export class Dice extends ChatCommand {
regexp = /^\/dice/i;
title = "/dice [emoji]";
description = "Sends random or specific dice";
async execute(msg: Message): Promise<void> {
const split = msg.text.split("/dice ");
const secondPart = split[1]?.trim();
const emojiIndex = emojis.indexOf(secondPart);
const emojiToDice: DiceEmoji = (emojiIndex >= 0 ? emojis[emojiIndex] : randomValue(emojis)) as DiceEmoji;
await bot.sendDice({
chat_id: msg.chat.id,
emoji: emojiToDice,
reply_parameters: {
message_id: msg.message_id
}
}).catch(logError);
}
}
+56
View File
@@ -0,0 +1,56 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {downloadTelegramFile, extractImageFileId, logError, replyToMessage, waveDistortSharp} from "../util/utils";
import {bot} from "../index";
export class Distort extends ChatCommand {
regexp = /^\/distort(?:@[\w_]+)?(?:\s+(\d+))?(?:\s+(\d+))?\s*$/i;
title = "/distort [amp] [wavelength]";
description = "Distortion of picture";
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
const chatId = msg.chat.id;
const reply = msg.reply_to_message;
if (!reply) {
await replyToMessage(
msg,
"Ответь командой /distort на сообщение с картинкой (фото, документ или стикер).\n" + "Пример: /distort 16 80"
);
return;
}
const fileId = extractImageFileId(reply);
if (!fileId) {
await replyToMessage(
msg,
"В реплае не вижу картинку. Пришли фото или файл-изображение."
);
return;
}
const amp = match?.[1] ? parseInt(match[1], 10) : 14;
const wavelength = match?.[2] ? parseInt(match[2], 10) : 72;
try {
await bot.sendChatAction({chat_id: chatId, action: "upload_photo"});
const file = await bot.getFile({file_id: fileId});
if (!file.file_path) throw new Error("No file_path in Telegram getFile response");
const inputBuf = await downloadTelegramFile(file.file_path);
const outBuf = await waveDistortSharp(inputBuf, amp, wavelength);
await bot.sendPhoto({
chat_id: chatId,
photo: outBuf,
caption: `Искажение готово ✅ (amp=${amp}, wavelength=${wavelength})`,
});
} catch (e) {
await replyToMessage(
msg, `Не получилось исказить изображение: ${e?.message ?? String(e)}`
).catch(logError);
}
}
}
+139
View File
@@ -0,0 +1,139 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {
collectReplyChainText,
editMessageText,
escapeMarkdownV2Text,
logError,
replyToMessage,
startIntervalEditor
} from "../util/utils";
import {Environment} from "../common/environment";
import {bot, googleAi} from "../index";
import {MessageStore} from "../common/message-store";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {ApiError} from "@google/genai";
export class GeminiChat extends ChatCommand {
regexp = /^\/gemini\s([^]+)/i;
title = "/gemini";
description = "Chat with AI (Gemini)";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
console.log("match", match);
return this.executeGemini(msg, match?.[1]);
}
async executeGemini(msg: Message, text: string): Promise<void> {
if (!text || text.trim().length === 0) return;
const chatId = msg.chat.id;
const messageParts = await collectReplyChainText(msg, "/gemini");
console.log("MESSAGE PARTS", messageParts);
const chatMessages = messageParts.map(part => {
return {
role: part.bot ? "ASSISTANT" : "USER",
content: part.content
};
});
chatMessages.reverse();
chatMessages.unshift({role: "SYSTEM", content: Environment.SYSTEM_PROMPT});
let chatContent = "";
for (const part of chatMessages) {
chatContent += `${part.role.toUpperCase()}:\n${part.content}\n\n`;
}
chatContent = chatContent.trim();
let waitMessage: Message;
const startTime = new Date().getSeconds();
try {
waitMessage = await bot.sendMessage({
chat_id: chatId,
text: Environment.waitText,
reply_parameters: {
chat_id: chatId,
message_id: msg.message_id
}
});
const stream = await googleAi.models.generateContentStream({
model: "gemini-2.5-flash",
contents: chatContent,
});
let messageText = "";
let shouldBreak = false;
let diff = 0;
const editor = startIntervalEditor({
intervalMs: 4500,
getText: () => messageText,
editFn: async (text) => {
await editMessageText(chatId, waitMessage.message_id, escapeMarkdownV2Text(text), "Markdown");
},
});
try {
for await (const chunk of stream) {
const text = chunk.text;
const length = (messageText + text).length;
if (length > 4096) {
messageText = messageText.slice(0, 4093) + "...";
shouldBreak = true;
} else {
messageText += text;
}
if (shouldBreak) {
console.log("messageText", messageText);
console.log("length", length);
console.log("break", true);
diff = Math.abs(new Date().getSeconds() - startTime);
await editor.tick();
await editor.stop();
break;
}
console.log("messageText", messageText);
console.log("length", messageText.length);
diff = Math.abs(new Date().getSeconds() - startTime);
}
} finally {
await editor.tick();
await editor.stop();
console.log("time", diff);
console.log("ended", true);
waitMessage.reply_to_message = msg;
waitMessage.text = messageText;
MessageStore.put(waitMessage);
await replyToMessage(waitMessage, `⏱️ ${diff}s`);
}
} catch (error) {
console.error(error);
if (error instanceof ApiError) {
if (error.status === 429) {
await replyToMessage(waitMessage, "На сегодня всё, лимиты закончились.").catch(logError);
return;
}
}
await replyToMessage(waitMessage, `Произошла ошибка!\n${error.toString()}`).catch(logError);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import {Message} from "typescript-telegram-bot-api";
import {chatCommandToString, delay, logError, sendMessage} from "../util/utils";
import {ChatCommand} from "../base/chat-command";
import {chatCommands} from "../index";
import {TelegramError} from "typescript-telegram-bot-api/dist/errors";
export class Help implements ChatCommand {
regexp = /^\/(h|help)/i;
title = "/help";
description = "Show list of commands";
async execute(msg: Message) {
let text = "Commands:\n\n";
chatCommands.forEach(c => {
text += `${chatCommandToString(c)}\n`;
});
await sendMessage({chatId: msg.from.id, text: text})
.then(async () => {
if (msg.chat.type !== "private") {
await sendMessage({message: msg, text: "Отправил команды в ЛС 😎"}).catch(logError);
}
})
.catch(async (e) => {
if (e instanceof TelegramError) {
if (e.response?.error_code === 403) {
await sendMessage({
message: msg,
text: "Не смог отправить команды в ЛС ☹️\nТогда отправлю сюда"
}).catch(logError);
await delay(1000);
await sendMessage({message: msg, text: text}).catch(logError);
}
}
});
}
}
+18
View File
@@ -0,0 +1,18 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
export class Id extends ChatCommand {
regexp = /^\/id/i;
title = "/id";
description = "ID of chat, user and reply (if replied to any message)";
async execute(msg: Message): Promise<void> {
let text = `chat id: \n\`\`\`${msg.chat.id}\`\`\` \nfrom id: \n\`\`\`${msg.from.id}\`\`\``;
if (msg.reply_to_message) {
text += ` \nreply id: \n\`\`\`${msg.reply_to_message.from.id}\`\`\``;
}
await replyToMessage(msg, text, "MarkdownV2").catch(logError);
}
}
+17
View File
@@ -0,0 +1,17 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {bot} from "../index";
export class Leave extends ChatCommand {
regexp = /^\/leave/i;
title = "/leave";
description = "Bot will leave current chat";
requirements = Requirements.Build(Requirement.BOT_ADMIN, Requirement.CHAT);
async execute(msg: Message): Promise<void> {
await bot.leaveChat({chat_id: msg.chat.id});
}
}
+39
View File
@@ -0,0 +1,39 @@
import {addMute} from "../db/database";
import {ChatCommand} from "../base/chat-command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {fullName, logError, oldSendMessage} from "../util/utils";
import {botUser} from "../index";
import {Environment} from "../common/environment";
export class Mute implements ChatCommand {
regexp = /^\/mute/i;
title = "/mute";
description = "Bot will ignore user";
requirements = Requirements.Build(Requirement.BOT_ADMIN, Requirement.REPLY);
async execute(msg: Message) {
if (!msg.reply_to_message) return;
const id = msg.reply_to_message.from.id;
const text = fullName(msg.reply_to_message.from);
if (id === botUser.id) {
await oldSendMessage(msg, "Бот не может сам себя игнорировать").catch(logError);
return;
}
if (id === Environment.CREATOR_ID) {
await oldSendMessage(msg, "Бот не будет игнорировать своего создателя").catch(logError);
return;
}
if (await addMute(id)) {
await oldSendMessage(msg, text + " в муте! 🔇").catch(logError);
} else {
await oldSendMessage(msg, text + " уже в муте 🤔").catch(logError);
}
}
}
+122
View File
@@ -0,0 +1,122 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {bot, ollama} from "../index";
import {
collectReplyChainText,
editMessageText,
escapeMarkdownV2Text,
extractText,
logError,
replyToMessage,
startIntervalEditor
} from "../util/utils";
import {Environment} from "../common/environment";
import {MessageStore} from "../common/message-store";
export class OllamaChat extends ChatCommand {
regexp = /^\/ollama\s([^]+)/;
title = "/ollama";
description = "talk to AI (Ollama)";
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
console.log("match", match);
return this.executeOllama(msg, match?.[1]);
}
async executeOllama(msg: Message, text: string): Promise<void> {
if (!text || text.trim().length === 0) return;
const chatId = msg.chat.id;
const messageParts = await collectReplyChainText(msg);
console.log("MESSAGE PARTS", messageParts);
const chatMessages = messageParts.map(part => {
return {
role: part.bot ? "ASSISTANT" : "USER",
content: extractText(part.content, Environment.BOT_PREFIX)
};
});
chatMessages.reverse();
chatMessages.unshift({role: "SYSTEM", content: Environment.SYSTEM_PROMPT});
let waitMessage: Message;
const startTime = Date.now();
try {
waitMessage = await bot.sendMessage({
chat_id: chatId,
text: Environment.waitText,
reply_parameters: {
chat_id: chatId,
message_id: msg.message_id
}
});
const stream = await ollama.chat({
model: Environment.OLLAMA_MODEL,
stream: true,
think: false,
keep_alive: 300,
messages: chatMessages
});
let currentText = "";
let shouldBreak = false;
const editor = startIntervalEditor({
intervalMs: 4500,
getText: () => currentText,
editFn: async (text) => {
await editMessageText(chatId, waitMessage.message_id, escapeMarkdownV2Text(text), "Markdown");
},
});
try {
for await (const chunk of stream) {
const content = chunk.message.content;
currentText += content;
const length = currentText.length;
if (length > 4096) {
currentText = currentText.slice(0, 4093) + "...";
shouldBreak = true;
}
if (shouldBreak || chunk.done) {
console.log("messageText", currentText);
console.log("length", length);
if (shouldBreak) {
console.log("break", true);
} else {
console.log("ended", true);
}
stream.abort();
const diff = Math.abs(Date.now() - startTime) / 1000;
await editor.tick();
await editor.stop();
waitMessage.reply_to_message = msg;
waitMessage.text = currentText;
MessageStore.put(waitMessage);
await replyToMessage(waitMessage, `⏱️ ${diff}s`);
break;
}
}
} finally {
await editor.tick();
await editor.stop();
}
} catch (error) {
console.error(error);
await replyToMessage(waitMessage, `Произошла ошибка!\n${error.toString()}`).catch(logError);
}
return Promise.resolve();
}
}
+24
View File
@@ -0,0 +1,24 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {oldSendMessage} from "../util/utils";
import {ollama} from "../index";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
export class OllamaKill extends ChatCommand {
regexp = /^\/killollama/i;
title = "/killOllama";
description = "dunno, do some shit";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message): Promise<void> {
try {
ollama.abort();
} catch (e) {
console.error(e);
}
await oldSendMessage(msg, "Остановил все генерации");
}
}
+115
View File
@@ -0,0 +1,115 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {bot, ollama} from "../index";
import {editMessageText, ignore, replyToMessage} from "../util/utils";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Environment} from "../common/environment";
export class OllamaPrompt extends ChatCommand {
regexp = /^\/ollamaprompt\s([^]+)/i;
title = "/ollamaPrompt";
description = "Custom prompt for AI (Ollama)";
requirements = Requirements.Build(Requirement.BOT_ADMIN);
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
console.log("match", match);
return this.executeOllama(msg, match?.[1]);
}
async executeOllama(msg: Message, text: string): Promise<void> {
if (!text || text.trim().length === 0) return;
const chatId = msg.chat.id;
let waitMessage: Message;
try {
waitMessage = await bot.sendMessage({
chat_id: chatId,
text: Environment.waitText,
reply_parameters: {
chat_id: chatId,
message_id: msg.message_id
},
parse_mode: "Markdown"
});
const stream = await ollama.chat({
model: Environment.OLLAMA_MODEL,
stream: true,
messages: [
{
role: "system",
content: text
}
]
});
let ended = false;
let messageText = "";
const interval = setInterval(async () => {
const length = messageText.length;
console.log("messageText", messageText);
console.log("length", length);
console.log("ended", ended);
await editMessageText(chatId, waitMessage.message_id, messageText);
if (ended) {
clearInterval(interval);
}
}, 4500);
let shouldBreak = false;
for await (const chunk of stream) {
messageText += chunk.message.content;
const length = messageText.length;
if (length > 4096) {
messageText = messageText.slice(0, 4093) + "...";
shouldBreak = true;
}
if (shouldBreak) {
console.log("messageText", messageText);
console.log("length", length);
console.log("break", true);
ended = true;
stream.abort();
clearInterval(interval);
const diff = Math.abs(new Date().getSeconds() - waitMessage.date);
messageText += `\n\nДумал ${diff}s`;
await editMessageText(chatId, waitMessage.message_id, messageText);
await replyToMessage(waitMessage, "Закончил лишь часть 😉");
break;
}
if (chunk.done) {
console.log("messageText", messageText);
console.log("length", messageText.length);
console.log("ended", true);
ended = true;
clearInterval(interval);
const diff = Math.abs(Date.now() / 1000 - waitMessage.date);
messageText += `\n\nДумал ${diff}s`;
await editMessageText(chatId, waitMessage.message_id, messageText);
await replyToMessage(waitMessage, "Закончил 😉");
}
}
} catch (error) {
console.error(error);
await editMessageText(chatId, waitMessage.message_id, `Произошла ошибка!\n${error.toString()}`)
.catch(async (e) => {
await editMessageText(chatId, waitMessage.message_id, `Произошла ошибка!\n${e.toString()}`).catch(ignore);
});
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import {ChatCommand} from "../base/chat-command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {bot, ollama} from "../index";
import {WebSearchResponse} from "../model/web-search-response";
import {editMessageText} from "../util/utils";
import {Environment} from "../common/environment";
export class OllamaSearch extends ChatCommand {
regexp = /^\/(s|search)\s([^]+)/;
title = "/search";
description = "Web search via Ollama";
override requirements = Requirements.Build(Requirement.BOT_ADMIN);
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
console.log("match", match);
const chatId = msg.chat.id;
try {
const wait = await bot.sendMessage({
chat_id: chatId,
text: Environment.waitText,
reply_parameters: {
chat_id: chatId,
message_id: msg.message_id
},
parse_mode: "Markdown"
});
const results = await ollama.webSearch({query: match?.[1]});
console.log("results", results);
let message = "Результаты:\n\n";
results.results.forEach((result, index) => {
const r = result as WebSearchResponse;
message += `${index + 1}. ${r.url}\n`;
});
await editMessageText(chatId, wait.message_id, message);
} catch (error) {
console.error(error);
}
return Promise.resolve();
}
}
+17
View File
@@ -0,0 +1,17 @@
import {logError, oldSendMessage} from "../util/utils";
import {Message} from "typescript-telegram-bot-api";
import {ChatCommand} from "../base/chat-command";
export class Ping implements ChatCommand {
regexp = /^\/ping/i;
title = "/ping";
description = "Ping between received and sent message";
async execute(msg: Message) {
const then = new Date().getMilliseconds();
await oldSendMessage(msg, "pong").catch(logError);
const now = new Date().getMilliseconds();
const diff = Math.abs(now - then);
await oldSendMessage(msg, `ping: ${diff}ms`).catch(logError);
}
}
+12
View File
@@ -0,0 +1,12 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {logError, randomValue, replyToMessage} from "../util/utils";
import {prefixAnswers} from "../db/database";
export class PrefixResponse extends ChatCommand {
regexp: RegExp;
async execute(msg: Message): Promise<void> {
await replyToMessage(msg, randomValue(prefixAnswers)).catch(logError);
}
}
+57
View File
@@ -0,0 +1,57 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {extractMessagePayload, logError, sendMessage} from "../util/utils";
import {bot} from "../index";
import QRCode from "qrcode";
export class Qr extends ChatCommand {
regexp = /^\/qr/i;
title = "/qr";
description = "Generates QR-code from text you sent or replied to.";
async execute(msg: Message): Promise<void> {
const chatId = msg.chat.id;
const split = msg.text?.split("/qr ");
const matchText = split[1];
const payload = extractMessagePayload(msg, matchText);
if (!payload) {
await sendMessage({
chatId: chatId,
text: "Отправь: /qr <текст или ссылка>\n" + "или ответь командой /qr на сообщение, из которого взять текст."
});
return;
}
if (payload.length > 1500) {
await sendMessage({
chatId: chatId,
text: `Слишком длинный текст для QR (${payload.length} символов). Максимум 1500 символов.`
});
return;
}
try {
await bot.sendChatAction({chat_id: chatId, action: "upload_photo"});
const pngBuffer = await QRCode.toBuffer(payload, {
type: "png",
errorCorrectionLevel: "L",
margin: 2,
scale: 8,
});
await bot.sendPhoto({
chat_id: chatId,
photo: pngBuffer,
caption: `QR готов ✅\nСодержимое: ${payload.length > 80 ? payload.slice(0, 80) + "…" : payload}`,
reply_parameters: {
message_id: msg.message_id,
}
});
} catch (e) {
await sendMessage({chatId: chatId, text: `Не получилось сгенерировать QR: ${e?.message ?? String(e)}`}).catch(logError);
}
}
}
+432
View File
@@ -0,0 +1,432 @@
import axios from "axios";
import sharp from "sharp";
import twemoji from "twemoji";
import emojiRegex from "emoji-regex";
import {createCanvas, GlobalFonts, type Image as CanvasImage, loadImage, SKRSContext2D} from "@napi-rs/canvas";
import {Message, PhotoSize} from "typescript-telegram-bot-api";
import {ChatCommand} from "../base/chat-command";
import {bot, botUser} from "../index";
import {
getChatAvatar,
getFileUrl,
getUserAvatar,
logError,
makeDarkGradientBgFancy,
oldSendMessage,
replyToMessage
} from "../util/utils";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
try {
GlobalFonts.registerFromPath("./assets/Inter_18pt-ExtraThin.ttf", "InterExtraThin");
GlobalFonts.registerFromPath("./assets/Inter_18pt-Thin.ttf", "InterThin");
GlobalFonts.registerFromPath("./assets/Inter_18pt-Light.ttf", "InterLight");
GlobalFonts.registerFromPath("./assets/Inter_18pt-Regular.ttf", "Inter");
GlobalFonts.registerFromPath("./assets/Inter_18pt-Medium.ttf", "InterMedium");
GlobalFonts.registerFromPath("./assets/Inter_18pt-SemiBold.ttf", "InterSemiBold");
GlobalFonts.registerFromPath("./assets/Inter_18pt-Bold.ttf", "InterBold");
GlobalFonts.registerFromPath("./assets/Inter_18pt-ExtraBold.ttf", "InterExtraBold");
GlobalFonts.registerFromPath("./assets/Inter_18pt-Black.ttf", "InterBlack");
} catch (e) {
console.error(e);
}
export class Quote extends ChatCommand {
regexp = /^\/(cit|q|quote)$/i;
title = "/quote";
description = "Make quote from text (or quote)";
requirements = Requirements.Build(Requirement.REPLY);
async execute(msg: Message): Promise<void> {
const chatId = msg.chat.id;
const reply = msg.reply_to_message;
if (!reply) {
await replyToMessage(msg, "Сделай /quote реплаем на сообщение 🙂").catch(logError);
return;
}
try {
const quoteRaw = (msg.quote?.text ?? reply.text ?? reply.caption ?? "").trim();
if (quoteRaw.length === 0) {
await replyToMessage(msg, "Не нашёл в сообщении текста 😢").catch(logError);
return;
}
let quote = quoteRaw.length ? quoteRaw : "…";
if (quote.length > 2500) quote = quote.slice(0, 2497) + "…";
const png = await renderQuoteCard(quote, reply);
await bot.sendPhoto({
chat_id: chatId,
photo: png,
reply_parameters: {
message_id: msg.message_id,
},
}).catch(logError);
} catch (e) {
console.error(e);
await oldSendMessage(msg, "Не смог собрать цитату 😢").catch(logError);
}
}
}
// ===== Emoji cache & helpers =====
const emojiCache = new Map<string, CanvasImage>();
function twemojiUrl(emoji: string) {
const code = twemoji.convert.toCodePoint(emoji);
return `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/${code}.png`;
}
async function loadEmoji(emoji: string): Promise<CanvasImage> {
const url = twemojiUrl(emoji);
const cached = emojiCache.get(url);
if (cached) return cached;
const res = await axios.get<ArrayBuffer>(url, {responseType: "arraybuffer"});
const img = await loadImage(Buffer.from(res.data));
emojiCache.set(url, img);
return img;
}
type Segment = { type: "text"; v: string } | { type: "emoji"; v: string };
function splitSegments(text: string): Segment[] {
const re = emojiRegex();
const out: Segment[] = [];
let last = 0;
for (const m of text.matchAll(re)) {
const i = m.index ?? 0;
if (i > last) out.push({type: "text", v: text.slice(last, i)});
out.push({type: "emoji", v: m[0]});
last = i + m[0].length;
}
if (last < text.length) out.push({type: "text", v: text.slice(last)});
return out;
}
function measure(ctx: SKRSContext2D, s: string) {
return ctx.measureText(s).width;
}
function wrapSegments(ctx: SKRSContext2D, segments: Segment[], maxW: number, emojiW: number) {
const lines: { segments: Segment[]; width: number }[] = [];
let cur: Segment[] = [];
let w = 0;
const push = () => {
lines.push({segments: cur, width: w});
cur = [];
w = 0;
};
const add = (seg: Segment, segW: number) => {
if (cur.length && w + segW > maxW) push();
cur.push(seg);
w += segW;
};
for (const seg of segments) {
if (seg.type === "emoji") {
add(seg, emojiW);
continue;
}
// переносы/пробелы
const parts = seg.v.split(/(\s+)/);
for (const p of parts) {
if (!p) continue;
const sub = p.split("\n");
for (let si = 0; si < sub.length; si++) {
const chunk = sub[si];
if (chunk) add({type: "text", v: chunk}, measure(ctx, chunk));
if (si !== sub.length - 1) push();
}
}
}
if (cur.length) push();
return lines;
}
function lineWidth(ctx: SKRSContext2D, segments: Segment[], fontSize: number) {
const emojiSize = Math.round(fontSize * 1.05);
let w = 0;
for (const s of segments) {
w += s.type === "emoji" ? emojiSize : ctx.measureText(s.v).width;
}
return w;
}
function addEllipsisToFit(ctx: SKRSContext2D, segments: Segment[], maxW: number, fontSize: number): Segment[] {
const emojiSize = Math.round(fontSize * 1.05);
const ell: Segment = {type: "text", v: "…"};
const ellW = ctx.measureText("…").width;
const out = segments.map((s) => ({...s})) as Segment[];
const widthOf = (arr: Segment[]) => {
let w = 0;
for (const s of arr) w += s.type === "emoji" ? emojiSize : ctx.measureText(s.v).width;
return w;
};
while (out.length && widthOf(out) + ellW > maxW) {
const last = out[out.length - 1];
if (last.type === "emoji") {
out.pop();
continue;
}
if (last.v.length <= 1) {
out.pop();
continue;
}
last.v = last.v.slice(0, -1);
}
return [...out, ell];
}
async function drawLine(ctx: SKRSContext2D, line: Segment[], x: number, baselineY: number, fontSize: number) {
const emojiSize = Math.round(fontSize * 1.05);
let cx = x;
for (const seg of line) {
if (seg.type === "text") {
ctx.fillText(seg.v, cx, baselineY);
cx += measure(ctx, seg.v);
} else {
const img = await loadEmoji(seg.v);
const y = baselineY - emojiSize + Math.round(fontSize * 0.2);
ctx.drawImage(img, cx, y, emojiSize, emojiSize);
cx += emojiSize;
}
}
}
type Fitted = {
fontSize: number;
lineH: number;
lines: { segments: Segment[]; width: number }[];
truncated: boolean;
};
function fitQuoteToBox(ctx: SKRSContext2D, quoteWithOpen: string, boxW: number, boxH: number): Fitted {
const MAX_FONT = 64;
const MIN_FONT = 18;
const endSuffix = " »";
const segments = splitSegments(quoteWithOpen);
for (let fontSize = MAX_FONT; fontSize >= MIN_FONT; fontSize -= 2) {
const emojiSize = Math.round(fontSize * 1.05);
ctx.font = `${fontSize}px Inter, sans-serif`;
const lines = wrapSegments(ctx, segments, boxW, emojiSize);
const lineH = Math.round(fontSize * 1.20);
const totalH = lines.length * lineH;
if (!lines.length) continue;
const endW = ctx.measureText(endSuffix).width;
const last = lines[lines.length - 1];
if (totalH <= boxH && last.width + endW <= boxW) {
last.segments = [...last.segments, {type: "text", v: endSuffix}];
last.width += endW;
return {fontSize: fontSize, lineH, lines, truncated: false};
}
}
const fontSize = MIN_FONT;
const emojiSize = Math.round(fontSize * 1.05);
ctx.font = `${fontSize}px Inter, sans-serif`;
const lineH = Math.round(fontSize * 1.20);
const maxLinesByHeight = Math.max(1, Math.floor(boxH / lineH));
let lines = wrapSegments(ctx, segments, boxW, emojiSize);
const endW = ctx.measureText(endSuffix).width;
if (lines.length > maxLinesByHeight) {
lines = lines.slice(0, maxLinesByHeight);
const last = lines[lines.length - 1];
if (last.width + endW > boxW) {
last.segments = addEllipsisToFit(ctx, last.segments, boxW - endW, fontSize);
last.width = lineWidth(ctx, last.segments, fontSize);
} else {
last.segments = addEllipsisToFit(ctx, last.segments, boxW - endW, fontSize);
last.width = lineWidth(ctx, last.segments, fontSize);
}
} else {
const last = lines[lines.length - 1];
if (last && last.width + endW > boxW) {
last.segments = addEllipsisToFit(ctx, last.segments, boxW - endW, fontSize);
last.width = lineWidth(ctx, last.segments, fontSize);
}
}
if (lines.length) {
const last = lines[lines.length - 1];
last.segments = [...last.segments, {type: "text", v: endSuffix}];
last.width += endW;
}
return {fontSize: fontSize, lineH, lines, truncated: true};
}
async function getBackground(
reply: Message,
W: number,
H: number,
author: QuoteAuthor,
isForwarded: boolean
): Promise<Buffer> {
let src: Buffer | null = null;
const photoArr = reply.photo as PhotoSize[] | undefined;
const msgPhoto = photoArr && photoArr.length ? photoArr[photoArr.length - 1] : undefined;
if (msgPhoto?.file_id) {
const url = await getFileUrl(bot, msgPhoto.file_id);
const res = await axios.get<ArrayBuffer>(url, {responseType: "arraybuffer"});
src = Buffer.from(res.data);
} else {
if (author.userId) {
src = await getUserAvatar(bot, author.userId);
} else if (author.chatId) {
src = await getChatAvatar(bot, author.chatId);
} else if (!isForwarded && reply.from?.id) {
src = await getUserAvatar(bot, reply.from.id);
}
}
if (!src) {
return makeDarkGradientBgFancy(W, H, `${reply.message_id}-${reply.date ?? ""}`);
// return sharp({create: {width: W, height: H, channels: 3, background: "#1f1f1f"}})
// .png()
// .toBuffer();
}
return sharp(src)
.resize(W, H, {fit: "cover"})
.blur(18)
.modulate({brightness: 0.75, saturation: 1.1})
.png()
.toBuffer();
}
async function renderQuoteCard(quote: string, reply: Message) {
const W = 1280;
const H = 720;
const author = getQuoteAuthor(reply);
const forwarded = !!reply.forward_origin;
const name = author.name;
const userTag = author.username ? `@${author.username}` : "";
const me = botUser;
const botTag = me.username ? `@${me.username}` : "@bot";
const date = new Date((reply.date ?? Math.floor(Date.now() / 1000)) * 1000);
const dd = String(date.getDate()).padStart(2, "0");
const mm = String(date.getMonth() + 1).padStart(2, "0");
const yyyy = String(date.getFullYear());
const dateStr = `${dd}.${mm}.${yyyy}`;
const bgBuf = await getBackground(reply, W, H, author, forwarded);
const canvas = createCanvas(W, H);
const c = canvas.getContext("2d");
const bgImg = await loadImage(bgBuf);
c.drawImage(bgImg, 0, 0, W, H);
c.fillStyle = "rgba(0,0,0,0.35)";
c.fillRect(0, 0, W, H);
const edgePad = 56;
const reservedBottom = 140;
const quoteBoxX = edgePad;
const quoteBoxW = W - edgePad * 2;
const quoteTop = 90;
const quoteBottom = H - reservedBottom;
const quoteH = quoteBottom - quoteTop;
c.fillStyle = "rgba(255,255,255,0.92)";
c.textBaseline = "alphabetic";
c.shadowColor = "rgba(0,0,0,0.55)";
c.shadowBlur = 10;
c.shadowOffsetY = 2;
const quoteForFit = `« ${quote}`;
const fitted = fitQuoteToBox(c, quoteForFit, quoteBoxW, quoteH);
c.font = `${fitted.fontSize}px InterSemiBold, sans-serif`;
const totalTextH = fitted.lines.length * fitted.lineH;
let y = quoteTop + (quoteH - totalTextH) / 2 + fitted.fontSize;
for (const ln of fitted.lines) {
const x = quoteBoxX + (quoteBoxW - ln.width) / 2;
await drawLine(c, ln.segments, x, y, fitted.fontSize);
y += fitted.lineH;
}
c.shadowBlur = 0;
c.shadowOffsetY = 0;
c.fillStyle = "rgba(255,255,255,0.70)";
c.font = "28px InterLight, Inter, sans-serif";
c.textAlign = "center";
c.fillText(userTag ? `${name} | ${userTag}` : name, W / 2, H - 86);
c.font = "22px InterMedium, sans-serif";
c.fillStyle = "rgba(255,255,255,0.45)";
c.textAlign = "left";
c.fillText(botTag, edgePad, H - 34);
c.textAlign = "right";
c.fillText(dateStr, W - edgePad, H - 34);
return canvas.toBuffer("image/png");
}
type QuoteAuthor = {
name: string;
username?: string;
userId?: number;
chatId?: number;
};
function getQuoteAuthor(reply: Message): QuoteAuthor {
const origin = reply.forward_origin;
if (origin) {
switch (origin.type) {
case "user": {
const u = origin.sender_user;
const name = [u.first_name, u.last_name].filter(Boolean).join(" ") || u.username || "Unknown";
return {name, username: u.username, userId: u.id};
}
case "hidden_user": {
const name = origin.sender_user_name || "Unknown";
return {name};
}
}
}
const u = reply.from!;
const name = [u.first_name, u.last_name].filter(Boolean).join(" ") || u.username || "Unknown";
return {name, username: u.username, userId: u.id};
}
+24
View File
@@ -0,0 +1,24 @@
import {ChatCommand} from "../base/chat-command";
import {getRandomInt, getRangedRandomInt, logError, oldSendMessage} from "../util/utils";
import {Message} from "typescript-telegram-bot-api";
export class RandomInt extends ChatCommand {
regexp = /^\/randomInt/i;
title = "/randomInt [min] [max]";
description = "Ranged random integer from parameters";
async execute(msg: Message) {
const split = msg.text.split(" ");
const min = parseInt(split[1]);
const max = parseInt(split[2]);
const good = max > min;
const sufficient = !!(min && max) && good;
const random = !sufficient ? getRandomInt(Math.pow(2, 60)) : getRangedRandomInt(min, max);
const randomText = !sufficient ? random.toString() : `[${min}; ${max}]: ${random}`;
await oldSendMessage(msg, randomText).catch(logError);
}
}
+26
View File
@@ -0,0 +1,26 @@
import {ChatCommand} from "../base/chat-command";
import {getRandomInt, logError, oldSendMessage} from "../util/utils";
import {Message} from "typescript-telegram-bot-api";
export class RandomString implements ChatCommand {
regexp = /^\/randomString/i;
title = "/randomString [length]";
description = "literally random string (up to 4096 symbols)";
async execute(msg: Message) {
const split = msg.text.split(" ");
const l = parseInt(split.length > 1 ? split[1] : "1");
const length = (l <= 0 || l > 4096) ? 1 : l;
let result = "";
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя0123456789";
for (let i = 0; i < length; i++) {
result += characters.charAt(getRandomInt(characters.length));
}
await oldSendMessage(msg, result).catch(logError);
}
}
+46
View File
@@ -0,0 +1,46 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {bot} from "../index";
import {delay, logError, randomValue} from "../util/utils";
const texts = [
"ну что-же, господа",
"приятно было с вами пообщаться",
"но мне пора на покой",
"всего хорошего"
];
const timings = [1500, 2500];
const timer = [3, 2, 1];
export class Shutdown extends ChatCommand {
regexp = /^\/shutdown/i;
title = "/shutdown";
description = "Self-destruction sequence for bot (shutdown)";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message): Promise<void> {
await bot.sendMessage({chat_id: msg.chat.id, text: "..."}).catch(logError);
if (msg.chat.type !== "private" && !msg.text.toLowerCase().startsWith("/shutdown now")) {
for (const text of texts) {
await delay(randomValue(timings));
await bot.sendMessage({chat_id: msg.chat.id, text: text}).catch(logError);
}
await delay(randomValue(timings));
for (const t of timer) {
await bot.sendMessage({chat_id: msg.chat.id, text: `${t}`}).catch(logError);
await delay(1000);
}
}
await bot.sendMessage({chat_id: msg.chat.id, text: "*R.I.P*"}).catch(logError);
delay(2000).then(() => process.exit(0));
}
}
+14
View File
@@ -0,0 +1,14 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {chatCommands} from "../index";
import {Help} from "./help";
export class Start extends ChatCommand {
regexp = /^\/start/i;
title = "/start";
description = "Start the bot";
async execute(msg: Message): Promise<void> {
await chatCommands.find(e => e instanceof Help).execute(msg);
}
}
+14
View File
@@ -0,0 +1,14 @@
import {ChatCommand} from "../base/chat-command";
import {logError, oldSendMessage} from "../util/utils";
import {Message} from "typescript-telegram-bot-api";
import {systemSpecsText} from "../index";
export class SystemSpecs implements ChatCommand {
regexp = /^\/systemspecs/i;
title = "/systemSpecs";
description = "System specifications of system";
async execute(msg: Message) {
await oldSendMessage(msg, systemSpecsText).catch(logError);
}
}
+14
View File
@@ -0,0 +1,14 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {logError, randomValue, replyToMessage} from "../util/utils";
import {testAnswers} from "../db/database";
export class Test implements ChatCommand {
regexp = /^(test|тест|еуые|ntcn|инноке(нтий|ш|нтич))/i;
title = "тест";
description = "System functionality check";
async execute(msg: Message) {
await replyToMessage(msg, randomValue(testAnswers) || "а").catch(logError);
}
}
+28
View File
@@ -0,0 +1,28 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {logError, replyToMessage} from "../util/utils";
import {bot} from "../index";
export class Title extends ChatCommand {
regexp = /^\/title\s([^]+)/;
title = "/title [title]";
description = "Change group title";
requirements = Requirements.Build(
Requirement.CHAT,
Requirement.BOT_ADMIN,
Requirement.BOT_CHAT_ADMIN
);
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
const title = (match?.[1] ?? "").trim();
if (title.length === 0) {
await replyToMessage(msg, "Не нашёл название...").catch(logError);
return;
}
await bot.setChatTitle({chat_id: msg.chat.id, title: title}).catch(logError);
}
}
+50
View File
@@ -0,0 +1,50 @@
import {ChatCommand} from "../base/chat-command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {bot, botUser} from "../index";
import {fullName, logError, oldSendMessage, replyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
export class Unban extends ChatCommand {
regexp = /^\/unban/i;
title = "/unban [reply]";
description = "unban user from chat";
requirements = Requirements.Build(
Requirement.CHAT,
Requirement.BOT_CHAT_ADMIN,
Requirement.REPLY,
Requirement.BOT_ADMIN
);
async execute(msg: Message) {
if (!msg.reply_to_message) return;
const user = msg.reply_to_message.from;
const userId = user.id;
if (userId === botUser.id) {
await replyToMessage(msg, "Бот и так не в бане сам у себя.").catch(logError);
return;
}
if (userId === Environment.CREATOR_ID) {
await replyToMessage(msg, "Создатель бота и так не в бане и никогда не будет.").catch(logError);
return;
}
if (msg.from.id !== Environment.CREATOR_ID && Environment.ADMIN_IDS.has(userId)) {
await replyToMessage(msg, "Админимтраторы бота и так не в бане.").catch(logError);
return;
}
bot.unbanChatMember({chat_id: msg.chat.id, user_id: userId})
.then(async () => {
await oldSendMessage(msg, `${fullName(user)} разбанен ⛓️‍💥`).catch(logError);
})
.catch(async () => {
await oldSendMessage(msg, `Не смог разбанить ${fullName(user)} ☹️`).catch(logError);
});
}
}
+38
View File
@@ -0,0 +1,38 @@
import {removeMute} from "../db/database";
import {ChatCommand} from "../base/chat-command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {fullName, logError, oldSendMessage} from "../util/utils";
import {Message} from "typescript-telegram-bot-api";
import {botUser} from "../index";
import {Environment} from "../common/environment";
export class Unmute implements ChatCommand {
regexp = /^\/unmute/i;
title = "/unmute";
description = "Bot will start responding to the user";
requirements = Requirements.Build(Requirement.BOT_ADMIN, Requirement.CHAT, Requirement.REPLY);
async execute(msg: Message) {
if (!msg.reply_to_message) return;
const id = msg.reply_to_message.from.id;
const text = fullName(msg.reply_to_message.from);
if (id === botUser.id) {
await oldSendMessage(msg, "Бот и так всегда к себе прислушивается").catch(logError);
return;
}
if (id === Environment.CREATOR_ID) {
await oldSendMessage(msg, "Бот всегда слушает своего создателя").catch(logError);
return;
}
if (await removeMute(id)) {
await oldSendMessage(msg, text + " больше не в муте! 🔈").catch(logError);
} else {
await oldSendMessage(msg, text + " не был в муте 🤔").catch(logError);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {getUptime, logError, oldSendMessage} from "../util/utils";
export class Uptime extends ChatCommand {
regexp = /^\/uptime/i;
title = "/uptime";
description = "Bot's uptime";
async execute(msg: Message): Promise<void> {
await oldSendMessage(msg, getUptime()).catch(logError);
}
}
+19
View File
@@ -0,0 +1,19 @@
import {ChatCommand} from "../base/chat-command";
import {logError, randomValue, oldSendMessage} from "../util/utils";
import {betterAnswers} from "../db/database";
import {Message} from "typescript-telegram-bot-api";
export class WhatBetter extends ChatCommand {
regexp = /^\/(what|что)\s(better|лучше)\s([^]+)\s(or|или)\s([^]+)/i;
title = "/what better [a] or [b]";
description = "either a or b randomly (50% chance)";
async execute(msg: Message, match?: RegExpExecArray) {
const a = match[3];
const b = match[5].trimStart();
const text = `${randomValue(betterAnswers)} ${randomValue([a, b])}`;
await oldSendMessage(msg, text).catch(logError);
}
}
+90
View File
@@ -0,0 +1,90 @@
import {ChatCommand} from "../base/chat-command";
import {getRandomInt, getRangedRandomInt, logError, replyToMessage} from "../util/utils";
import {Message} from "typescript-telegram-bot-api";
export class When extends ChatCommand {
regexp = /^\/(when|когда)\s([^]+)/i;
title = "/when [value]";
description = "random date";
async execute(msg: Message) {
let text = "через ";
const type = getRandomInt(8);
switch (type) {
case 0:
text = "сейчас";
break;
case 1:
text = "никогда";
break;
case 2: //seconds
{
const seconds = getRangedRandomInt(1, 60);
text += `${seconds} `;
text += (
(seconds == 1 || seconds % 10 == 1) ? "секунду" :
((seconds > 1 && seconds < 5) || (seconds % 10 > 1 && seconds % 10 < 5)) ? "секунды" : "секунд"
);
break;
}
case 3: {
const minutes = getRangedRandomInt(1, 60);
text += `${minutes} `;
text += (
(minutes == 1 || minutes % 10 == 1) ? "минуту" :
((minutes > 1 && minutes < 5) || (minutes % 10 > 1 && minutes % 10 < 5)) ? "минуты" : "минут"
);
break;
}
case 4: {
const hours = getRangedRandomInt(1, 24);
text += `${hours} `;
text += (
(hours == 1 || hours % 10 == 1) ? "час" :
((hours > 1 && hours < 5) || (hours % 10 > 1 && hours % 10 < 5)) ? "часа" : "часов"
);
break;
}
case 5: {
const weeks = getRangedRandomInt(1, 4);
text += `${weeks} `;
text += (weeks == 1 ? "неделю" : "недель");
break;
}
case 6: {
const months = getRandomInt(12);
text += `${months} `;
text += (
(months == 1 || months % 10 == 1) ? "месяц" :
((months > 1 && months < 5) || (months % 10 > 1 && months % 10 < 5)) ? "месяца" : "месяцев"
);
break;
}
case 7: {
const years = getRangedRandomInt(1, 100);
text += `${years} `;
text += (
(years == 1 || years % 10 == 1) ? "год" :
((years > 1 && years < 5) || (years % 10 > 1 && years % 10 < 5)) ? "года" : "лет"
);
break;
}
}
await replyToMessage(msg, text).catch(logError);
}
}
+74
View File
@@ -0,0 +1,74 @@
import path from "node:path";
import {saveData} from "../db/database";
export class Environment {
static BOT_TOKEN: string;
static TEST_ENVIRONMENT: boolean;
static ADMIN_IDS: Set<number> = new Set<number>();
static CHAT_IDS_WHITELIST: Set<number> = new Set<number>();
static BOT_PREFIX: string;
static CREATOR_ID: number;
static IS_DOCKER: boolean;
static DATA_PATH: string;
static DB_FILE_NAME: string = "database.db";
static DB_PATH: string;
static USE_MOM: boolean;
static USE_DAD: boolean;
static USE_FU: boolean;
static OLLAMA_MODEL: string;
static OLLAMA_ADDRESS: string;
static OLLAMA_API_KEY?: string;
static SYSTEM_PROMPT: string;
static GEMINI_API_KEY: string;
static waitText = "⏳ Дайте-ка подумать...";
static load() {
Environment.BOT_TOKEN = process.env.BOT_TOKEN;
Environment.TEST_ENVIRONMENT = process.env.TEST_ENVIRONMENT === "true";
Environment.CHAT_IDS_WHITELIST = new Set(process.env.CHAT_IDS_WHITELIST?.split(",")?.map(e => parseInt(e.trim(), 10)) || []);
Environment.BOT_PREFIX = process.env.BOT_PREFIX || "";
Environment.CREATOR_ID = parseInt(process.env.CREATOR_ID || "");
Environment.IS_DOCKER = process.env.IS_DOCKER == "true";
Environment.DATA_PATH = Environment.IS_DOCKER ? path.join("", "config", "data") : "data";
Environment.DB_PATH = "file:" + path.join(Environment.DATA_PATH, Environment.DB_FILE_NAME);
Environment.USE_MOM = process.env.USE_MOM == "true";
Environment.USE_DAD = process.env.USE_DAD == "true";
Environment.USE_FU = process.env.USE_FU == "true";
Environment.OLLAMA_MODEL = process.env.OLLAMA_MODEL || "llama3.2";
Environment.OLLAMA_ADDRESS = process.env.OLLAMA_ADDRESS || "127.0.0.1";
Environment.OLLAMA_API_KEY = process.env.OLLAMA_API_KEY;
Environment.SYSTEM_PROMPT = (process.env.SYSTEM_PROMPT?.trim()) || "";
Environment.GEMINI_API_KEY = process.env.GEMINI_API_KEY;
}
static setAdmins(admins: Set<number>) {
this.ADMIN_IDS = admins;
}
static async addAdmin(id: number): Promise<boolean> {
const has = this.ADMIN_IDS.has(id);
if (!has) {
this.ADMIN_IDS.add(id);
await saveData();
}
return !has;
}
static async removeAdmin(id: number): Promise<boolean> {
const has = this.ADMIN_IDS.has(id);
if (has) {
this.ADMIN_IDS.delete(id);
await saveData();
}
return has;
}
}
+5
View File
@@ -0,0 +1,5 @@
export type MessagePart = {
bot: boolean;
name?: string;
content: string;
}
+44
View File
@@ -0,0 +1,44 @@
import {StoredMessage} from "../model/stored-message";
import {Message} from "typescript-telegram-bot-api";
import {extractTextMessage} from "../util/utils";
import {Environment} from "./environment";
import {messageDao} from "../index";
export class MessageStore {
private static map = new Map<string, StoredMessage>();
private static key(chatId: number, messageId: number) {
return `${chatId}:${messageId}`;
}
static all(): Map<string, StoredMessage> {
return this.map;
}
static async put(m: Message, prefix: string = Environment.BOT_PREFIX) {
const msg: StoredMessage = {
chatId: m.chat.id,
messageId: m.message_id,
replyToMessageId: m.reply_to_message?.message_id ?? null,
fromId: m.from.id,
text: extractTextMessage(m, prefix),
date: m.date ?? 0,
};
this.map.set(this.key(m.chat.id, m.message_id), msg);
await messageDao.insert(messageDao.mapTo([m]));
}
static async get(chatId: number, messageId: number): Promise<StoredMessage | null> {
const message = await messageDao.getById({chatId: chatId, id: messageId});
if (!message) return null;
this.map.set(this.key(message.chatId, messageId), message);
return message;
}
static clear() {
this.map.clear();
}
}
+38
View File
@@ -0,0 +1,38 @@
import {User} from "typescript-telegram-bot-api";
import {userDao} from "../index";
import {StoredUser} from "../model/stored-user";
export class UserStore {
private static map = new Map<number, StoredUser>();
static all(): Map<number, StoredUser> {
return this.map;
}
static async put(u: User) {
const user: StoredUser = {
id: u.id,
isBot: u.is_bot,
firstName: u.first_name,
lastName: u.last_name,
userName: u.username,
isPremium: u.is_premium,
};
this.map.set(u.id, user);
await userDao.insert(userDao.mapTo([u]));
}
static async get(id: number): Promise<StoredUser | null> {
const user = await userDao.getById({id: id});
if (!user) return null;
this.map.set(id, user);
return user;
}
static clear() {
this.map.clear();
}
}
+12
View File
@@ -0,0 +1,12 @@
import "dotenv/config";
import {drizzle, LibSQLDatabase} from "drizzle-orm/libsql";
import {Environment} from "../common/environment";
export class DatabaseManager {
static db: LibSQLDatabase;
static init() {
DatabaseManager.db = drizzle(Environment.DB_PATH);
}
}
+97
View File
@@ -0,0 +1,97 @@
import * as fs from "fs";
import {Environment} from "../common/environment";
export let muted: Set<number> = new Set<number>();
type DataJsonFile = {
admins: number[]
muted: number[]
}
export let jsonFile: DataJsonFile;
type AnswersJsonFile = {
test: string[]
prefix: string[]
better: string[]
who: string[]
kick: string[]
invite: string[]
day: number[]
}
export const testAnswers: string[] = [];
export const prefixAnswers: string[] = [];
export const betterAnswers: string[] = [];
export const whoAnswers: string[] = [];
export const kickAnswers: string[] = [];
export const inviteAnswers: string[] = [];
export const dayAnswers: number[] = [];
export async function addMute(id: number): Promise<boolean> {
if (muted.has(id)) return Promise.resolve(false);
muted.add(id);
await saveData();
return Promise.resolve(true);
}
export async function removeMute(id: number): Promise<boolean> {
if (!muted.has(id)) return Promise.resolve(false);
muted.delete(id);
await saveData();
return Promise.resolve(true);
}
export async function readData(): Promise<void> {
try {
jsonFile = JSON.parse(fs.readFileSync(`${Environment.DATA_PATH}/data.json`).toString());
const admins = jsonFile.admins || [];
admins.unshift(Environment.CREATOR_ID);
Environment.setAdmins(new Set<number>(admins));
muted = new Set<number>(jsonFile.muted || []);
return Promise.resolve();
} catch (e) {
console.error(e);
return Promise.reject(e);
}
}
export async function saveData(): Promise<void> {
const adminIds: number[] = [];
Environment.ADMIN_IDS.forEach(id => adminIds.push(id));
jsonFile.admins = adminIds;
const mutedList: number[] = [];
muted.forEach(id => mutedList.push(id));
jsonFile.muted = mutedList;
try {
fs.writeFileSync(`${Environment.DATA_PATH}/data.json`, JSON.stringify(jsonFile));
return readData();
} catch (e) {
return Promise.reject(e);
}
}
export async function retrieveAnswers(): Promise<void> {
try {
const json: AnswersJsonFile = JSON.parse(fs.readFileSync(`${Environment.DATA_PATH}/answers.json`).toString());
json.test.forEach(e => testAnswers.push(e));
json.prefix.forEach(e => prefixAnswers.push(e));
json.better.forEach(e => betterAnswers.push(e));
json.who.forEach(e => whoAnswers.push(e));
json.kick.forEach(e => kickAnswers.push(e));
json.invite.forEach(e => inviteAnswers.push(e));
json.day.forEach(e => dayAnswers.push(e));
return Promise.resolve();
} catch (e) {
console.error(e);
return Promise.reject(e);
}
}
+113
View File
@@ -0,0 +1,113 @@
import {messagesTable} from "./schema";
import {DatabaseManager} from "./database-manager";
import {StoredMessage} from "../model/stored-message";
import {and, eq} from "drizzle-orm";
import {inArray} from "drizzle-orm/sql/expressions/conditions";
import {Message} from "typescript-telegram-bot-api";
import {Dao} from "../base/dao";
import {buildExcludedSet} from "../util/utils";
export class MessageDao extends Dao<StoredMessage> {
private tag: string = "MessageDao";
override async getAll(): Promise<StoredMessage[]> {
const then = Date.now();
const messages = await DatabaseManager.db.select().from(messagesTable);
const now = Date.now();
const diff = now - then;
console.log(`${this.tag}: getAll()`, `took ${diff}ms; size: ${messages.length}`);
return this.mapFrom(messages);
}
override async getById(params: { chatId: number, id: number }): Promise<StoredMessage | null> {
const then = Date.now();
const messages =
await DatabaseManager.db.select()
.from(messagesTable)
.where(
and(
eq(messagesTable.chatId, params.chatId),
eq(messagesTable.id, params.id)
)
);
const now = Date.now();
const diff = now - then;
console.log(`${this.tag}: getById(${params.chatId}, ${params.id})`, `took ${diff}ms; size: ${messages.length}`);
const m = messages[0];
if (!m) return null;
return this.mapFrom([m])[0];
}
override async getByIds(params: { chatId: number, ids: number[] }): Promise<StoredMessage[]> {
const then = Date.now();
const messages =
await DatabaseManager.db.select()
.from(messagesTable)
.where(
and(
eq(messagesTable.chatId, params.chatId),
inArray(messagesTable.id, params.ids)
)
);
const now = Date.now();
const diff = now - then;
console.log(`${this.tag}: getByIds(${params.chatId}, ${params.ids})`, `took ${diff}ms; size: ${messages.length}`);
return this.mapFrom(messages);
}
async insert(values: typeof messagesTable.$inferInsert[]): Promise<true> {
const then = Date.now();
const r = await DatabaseManager.db
.insert(messagesTable)
.values(values)
.onConflictDoUpdate({
target: messagesTable.id,
set: buildExcludedSet(messagesTable, ["id"])
});
const now = Date.now();
const diff = now - then;
console.log(`${this.tag}: insert(size: ${values.length})`, `took ${diff}ms'; inserted: ${r.rowsAffected}`);
return true;
}
mapTo(messages: Message[]): typeof messagesTable.$inferInsert[] {
return messages.map(msg => {
return {
chatId: msg.chat.id,
id: msg.message_id,
replyToMessageId: msg.reply_to_message?.message_id,
fromId: msg.from.id,
text: msg.text,
date: msg.date,
firstName: msg.from.first_name,
lastName: msg.from.last_name,
};
});
}
mapFrom(messages: typeof messagesTable.$inferInsert[]): StoredMessage[] {
return messages.map(m => {
return {
firstName: m.firstName,
lastName: m.lastName,
chatId: m.chatId,
messageId: m.id,
replyToMessageId: m.replyToMessageId,
fromId: m.fromId,
text: m.text,
date: m.date
};
});
}
}
+25
View File
@@ -0,0 +1,25 @@
import {int, sqliteTable, text} from "drizzle-orm/sqlite-core";
export const messagesTable = sqliteTable("messages", {
id: int().primaryKey().unique().notNull(),
chatId: int().notNull(),
replyToMessageId: int(),
fromId: int().notNull(),
text: text().notNull(),
date: int().notNull(),
firstName: text().notNull(),
lastName: text(),
});
export type MessageInsert = typeof messagesTable.$inferInsert;
export const usersTable = sqliteTable("users", {
id: int().primaryKey().unique().notNull(),
isBot: int().notNull(),
firstName: text().notNull(),
lastName: text(),
userName: text(),
isPremium: int(),
});
export type UserInsert = typeof usersTable.$inferInsert;
+105
View File
@@ -0,0 +1,105 @@
import {StoredUser} from "../model/stored-user";
import {Dao} from "../base/dao";
import {DatabaseManager} from "./database-manager";
import {UserInsert, usersTable} from "./schema";
import {eq} from "drizzle-orm";
import {inArray} from "drizzle-orm/sql/expressions/conditions";
import {User} from "typescript-telegram-bot-api";
import {boolToInt, buildExcludedSet} from "../util/utils";
export class UserDao extends Dao<StoredUser> {
private tag: string = "UserDao";
override async getAll(): Promise<StoredUser[]> {
const then = Date.now();
const users = await DatabaseManager.db.select().from(usersTable);
const now = Date.now();
const diff = now - then;
console.log(`${this.tag}: getAll()`, `took ${diff}ms; size: ${users.length}`);
return this.mapFrom(users);
}
override async getById(params: { id: number }): Promise<StoredUser | null> {
const then = Date.now();
const users =
await DatabaseManager.db.select()
.from(usersTable)
.where(
eq(usersTable.id, params.id)
);
const now = Date.now();
const diff = now - then;
console.log(`${this.tag}: getById(${params.id})`, `took ${diff}ms; size: ${users.length}`);
const u = users[0];
if (!u) return null;
return this.mapFrom([u])[0];
}
override async getByIds(params: { ids: number[] }): Promise<StoredUser[]> {
const then = Date.now();
const users =
await DatabaseManager.db.select()
.from(usersTable)
.where(
inArray(usersTable.id, params.ids)
);
const now = Date.now();
const diff = now - then;
console.log(`${this.tag}: getByIds(${params.ids})`, `took ${diff}ms; size: ${users.length}`);
return this.mapFrom(users);
}
override async insert(values: UserInsert[] | UserInsert): Promise<true> {
const rows = Array.isArray(values) ? values : [values];
const then = Date.now();
const r = await DatabaseManager.db
.insert(usersTable)
.values(rows)
.onConflictDoUpdate({
target: usersTable.id,
set: buildExcludedSet(usersTable, ["id"])
});
const now = Date.now();
const diff = now - then;
console.log(`${this.tag}: insert(size: ${rows.length})`, `took ${diff}ms; inserted: ${r.rowsAffected}`);
return true;
}
mapTo(users: User[]): UserInsert[] {
return users.map(u => {
return {
id: u.id,
isBot: boolToInt(u.is_bot),
firstName: u.first_name,
lastName: u.last_name,
userName: u.username,
isPremium: boolToInt(u.is_premium)
};
});
}
mapFrom(users: UserInsert[]): StoredUser[] {
return users.map(u => {
return {
id: u.id,
isBot: u.isBot === 1,
firstName: u.firstName,
lastName: u.lastName,
userName: u.userName,
isPremium: u.isPremium === 1
};
});
}
}
+263
View File
@@ -0,0 +1,263 @@
import "dotenv/config";
import {Environment} from "./common/environment";
import {InlineQueryResult, TelegramBot, User} from "typescript-telegram-bot-api";
import {ChatCommand} from "./base/chat-command";
import {
checkRequirements,
executeChatCommand,
extractTextMessage,
initSystemSpecs,
logError,
randomValue,
searchChatCommand
} from "./util/utils";
import {Ae} from "./commands/ae";
import {Help} from "./commands/help";
import {Mute} from "./commands/mute";
import {Unmute} from "./commands/unmute";
import {Ping} from "./commands/ping";
import {RandomString} from "./commands/random-string";
import {SystemSpecs} from "./commands/system-specs";
import {Test} from "./commands/test";
import {inviteAnswers, kickAnswers, muted, readData, retrieveAnswers} from "./db/database";
import {Uptime} from "./commands/uptime";
import {WhatBetter} from "./commands/what-better";
import {When} from "./commands/when";
import {RandomInt} from "./commands/random-int";
import {Ban} from "./commands/ban";
import {Quote} from "./commands/quote";
import {Ollama} from "ollama";
import {WebSearchResponse} from "./model/web-search-response";
import {OllamaSearch} from "./commands/ollama-search";
import {Id} from "./commands/id";
import {OllamaPrompt} from "./commands/ollama-prompt";
import {AdminsAdd} from "./commands/admins-add";
import {AdminsRemove} from "./commands/admins-remove";
import {Shutdown} from "./commands/shutdown";
import {OllamaKill} from "./commands/ollama-kill";
import {Leave} from "./commands/leave";
import {OllamaChat} from "./commands/ollama-chat";
import {Start} from "./commands/start";
import {MessageStore} from "./common/message-store";
import {PrefixResponse} from "./commands/prefix-response";
import {GoogleGenAI} from "@google/genai";
import {GeminiChat} from "./commands/gemini-chat";
import {Choice} from "./commands/choice";
import {Coin} from "./commands/coin";
import {Qr} from "./commands/qr";
import {Distort} from "./commands/distort";
import {CacheSize} from "./commands/cache-size";
import {CacheClear} from "./commands/cache-clear";
import {Dice} from "./commands/dice";
import {Unban} from "./commands/unban";
import {Title} from "./commands/title";
import {MessageDao} from "./db/message-dao";
import {DatabaseManager} from "./db/database-manager";
import {UserDao} from "./db/user-dao";
import {UserStore} from "./common/user-store";
process.setUncaughtExceptionCaptureCallback(console.error);
Environment.load();
DatabaseManager.init();
export const messageDao = new MessageDao();
export const userDao = new UserDao();
export const bot = new TelegramBot({botToken: Environment.BOT_TOKEN, testEnvironment: Environment.TEST_ENVIRONMENT});
export let botUser: User;
export const ollama = new Ollama({
host: Environment.OLLAMA_ADDRESS,
headers: {"Authorization": `Bearer ${Environment.OLLAMA_API_KEY}`}
});
export const googleAi = new GoogleGenAI({apiKey: Environment.GEMINI_API_KEY});
export let systemSpecsText: string = "";
export function setSystemSpecs(systemSpecs: string) {
systemSpecsText = systemSpecs;
}
export const chatCommands: ChatCommand[] = [
new Start(),
new Help(),
new Test(),
new Ae(),
new Mute(),
new Unmute(),
new Ping(),
new RandomInt(),
new RandomString(),
new SystemSpecs(),
new Uptime(),
new WhatBetter(),
new When(),
new Ban(),
new Unban(),
new Quote(),
new Id(),
new Choice(),
new Coin(),
new Qr(),
new Distort(),
new Dice(),
new Title(),
new AdminsAdd(),
new AdminsRemove(),
new Shutdown(),
new Leave(),
new OllamaChat(),
new OllamaSearch(),
new OllamaPrompt(),
new OllamaKill(),
new GeminiChat(),
new CacheSize(),
new CacheClear()
];
async function main() {
try {
const messages = await messageDao.getAll();
const users = await userDao.getAll();
console.log("Messages: ", messages);
console.log("Users: ", users);
const results = await Promise.all(
[
initSystemSpecs(), readData(), retrieveAnswers(),
bot.getMe()
]
);
botUser = results[3];
await UserStore.put(botUser);
await bot.startPolling();
console.log(`Bot started! testEnvironment: ${Environment.TEST_ENVIRONMENT}`);
} catch (error) {
console.error(error);
}
}
bot.on("my_chat_member", async (u) => {
console.log("my_chat_member", u);
});
bot.on("message", async (message) => {
console.log("message", message);
await UserStore.put(message.from);
if ((message.new_chat_members?.length || 0 > 0)) {
await bot.sendMessage({chat_id: message.chat.id, text: randomValue(inviteAnswers)}).catch(logError);
return;
}
if (message.left_chat_member && message.left_chat_member.id !== botUser.id) {
await bot.sendMessage({chat_id: message.chat.id, text: randomValue(kickAnswers)}).catch(logError);
return;
}
});
bot.on("edited_message", async (msg) => {
console.log("edited_message", msg);
await UserStore.put(msg.from);
if (!extractTextMessage(msg) || msg.from.id === botUser.id) return;
await MessageStore.put(msg);
});
bot.on("message:text", async (msg) => {
await MessageStore.put(msg);
if (muted.has(msg.from.id)) return;
if (msg.forward_origin) return;
const cmdText = msg.text || msg.caption || "";
const cmd = searchChatCommand(chatCommands, cmdText);
const executed = await executeChatCommand(cmd, msg, cmdText);
if (executed || !cmdText) return;
const startsWithPrefix = cmdText.toLowerCase().startsWith(Environment.BOT_PREFIX.toLowerCase());
const messageWithoutPrefix = cmdText.substring(Environment.BOT_PREFIX.length).trim();
if (startsWithPrefix && messageWithoutPrefix.length === 0) {
const prefixResponse = new PrefixResponse();
if (await checkRequirements(prefixResponse, msg)) {
await prefixResponse.execute(msg);
}
return;
}
if (!startsWithPrefix && msg.chat.type !== "private") return;
if (msg.chat.type === "private" && !Environment.ADMIN_IDS.has(msg.chat.id)) return;
const chat = chatCommands.find(e => e instanceof OllamaChat);
if (await checkRequirements(chat, msg)) {
await chat.executeOllama(msg, startsWithPrefix ? messageWithoutPrefix : cmdText);
}
});
bot.on("inline_query", async (query) => {
console.log("query", query);
if (Environment.CREATOR_ID !== query.from.id) {
await bot.answerInlineQuery({
inline_query_id: query.id,
results: [],
button: {
text: "No access",
start_parameter: "nope"
}
}).catch(logError);
return;
}
if (query.query.trim().length !== 0) {
try {
const queryResults: InlineQueryResult[] = [];
const results = await ollama.webSearch({query: query.query});
console.log("results", results);
results.results.forEach((result, i) => {
const r = result as WebSearchResponse;
queryResults.push({
type: "article",
id: `${i}`,
title: `${r.title}`,
input_message_content: {
message_text: `${r.title}\n\n${r.url}`
}
});
});
await bot.answerInlineQuery({
inline_query_id: query.id,
results: queryResults,
});
} catch (e) {
console.error(e);
}
} else {
await bot.answerInlineQuery({
inline_query_id: query.id,
results: [],
}).catch(logError);
}
});
main().catch(console.error);
+8
View File
@@ -0,0 +1,8 @@
export type StoredMessage = {
chatId: number;
messageId: number;
replyToMessageId?: number | null;
fromId: number;
text: string;
date: number;
};
+8
View File
@@ -0,0 +1,8 @@
export type StoredUser = {
id: number;
isBot: boolean;
firstName: string;
lastName?: string;
userName?: string;
isPremium?: boolean;
}
+5
View File
@@ -0,0 +1,5 @@
export interface WebSearchResponse {
title: string;
url: string;
content: string;
}
+731
View File
@@ -0,0 +1,731 @@
import * as si from "systeminformation";
import {ChatCommand} from "../base/chat-command";
import {CallbackCommand} from "../base/callback-command";
import {
CallbackQuery,
InlineKeyboardMarkup,
Message,
ParseMode,
PhotoSize,
TelegramBot,
User
} from "typescript-telegram-bot-api";
import {Environment} from "../common/environment";
import {TelegramError} from "typescript-telegram-bot-api/dist/errors";
import {bot, botUser, messageDao, setSystemSpecs} from "../index";
import os from "os";
import axios from "axios";
import {MessagePart} from "../common/message-part";
import {StoredMessage} from "../model/stored-message";
import sharp from "sharp";
import {UserStore} from "../common/user-store";
import * as orm from "drizzle-orm";
import {sql, type SQL} from "drizzle-orm";
export const ignore = () => {
};
export const ignoreIfNotChanged = (e: Error | TelegramError) => {
if (!(e instanceof TelegramError && e?.response?.description?.startsWith("Bad Request: message is not modified"))) {
throw e;
}
};
export const ignoreIfMarkupFailed = (e: Error | TelegramError) => {
if (!(e instanceof TelegramError && e?.response?.description?.startsWith("Bad Request: can't parse entities"))) {
throw e;
}
};
export const logError = (e: Error | TelegramError) => {
console.error(e);
};
export const errorPlaceholder = async (msg: Message) => {
await sendErrorPlaceholder(msg).catch(logError);
};
export function searchChatCommand(commands: ChatCommand[], text: string): ChatCommand | null {
for (let i = 0; i < commands.length; i++) {
const command = commands[i];
if (command?.regexp.test(text)) {
return command;
}
}
return null;
}
export function searchCallbackCommand(commands: CallbackCommand[], data: string): CallbackCommand | null {
for (let i = 0; i < commands.length; i++) {
const command = commands[i];
if (!command?.data) continue;
if (data.startsWith(command.data)) {
return command;
}
}
return null;
}
export async function checkRequirements(cmd: ChatCommand | null, msg: Message): Promise<boolean> {
if (!cmd) return false;
const fromId = msg.from?.id || -1;
if (Environment.CHAT_IDS_WHITELIST.size > 0 &&
!Environment.CHAT_IDS_WHITELIST.has(msg.chat.id) &&
!Environment.ADMIN_IDS.has(msg.chat.id) &&
!Environment.ADMIN_IDS.has(msg.from.id)) {
console.log(`${cmd.title}: chatId whitelist ignored.`);
return false;
}
const reqs = cmd.requirements;
if (!reqs) return true;
if (reqs.isRequiresBotCreator() && fromId !== Environment.CREATOR_ID) {
console.log(`${cmd.title}: creatorId is bad`);
await replyToMessage(msg, "Вы не являетесь создателем бота.");
return false;
}
if (reqs.isRequiresBotAdmin() && !Environment.ADMIN_IDS.has(fromId)) {
console.log(`${cmd.title}: adminId is bad`);
await replyToMessage(msg, "Вы не являетесь администратором бота.");
return false;
}
if (reqs.isRequiresBotChatAdmin() && msg.chat.type !== "private") {
const member = await bot.getChatMember({chat_id: msg.chat.id, user_id: botUser.id});
const isAdmin = member.status === "administrator" || member.status === "creator";
if (!isAdmin) {
console.log(`${cmd.title}: chatAdminId is bad`);
await replyToMessage(msg, "Бот не является администратором чата.");
return false;
}
}
if (reqs.isRequiresChat() && msg.chat.type === "private") {
console.log(`${cmd.title}: chatId is bad`);
await replyToMessage(msg, "Тут Вам не чат.");
return false;
}
if (reqs.isRequiresReply() && !msg.reply_to_message) {
console.log(`${cmd.title}: replyMessage is bad`);
await replyToMessage(msg, "Отсутствует ответ на сообщение.");
return false;
}
return true;
}
export async function executeChatCommand(cmd: ChatCommand | null, msg: Message, text: string): Promise<boolean> {
if (!cmd) return false;
if (!await checkRequirements(cmd, msg)) return false;
await cmd.execute(msg, cmd.regexp.exec(text));
return true;
}
export async function findAndExecuteCallbackCommand(commands: CallbackCommand[], query: CallbackQuery): Promise<boolean> {
const fromId = query.from.id;
const data = query.data || "";
const command = searchCallbackCommand(commands, data);
if (!command) return false;
const requirements = command.requirements;
if (requirements) {
if (requirements.isRequiresBotAdmin() && !Environment.ADMIN_IDS.has(fromId)) {
console.log(`${command.data}: adminId is bad: ${fromId}`);
return false;
}
}
await command.execute(query);
await command.answerCallbackQuery(query);
await command.afterExecute(query);
return true;
}
export async function editMessageText(chatId: number, messageId: number, messageText: string, parseMode?: ParseMode, replyMarkup?: InlineKeyboardMarkup): Promise<void> {
if (messageText.trim().length === 0) return Promise.resolve();
try {
await bot.editMessageText({
chat_id: chatId,
message_id: messageId,
text: messageText,
parse_mode: parseMode,
link_preview_options: {
is_disabled: true
},
reply_markup: replyMarkup
}).catch(ignoreIfMarkupFailed);
return Promise.resolve();
} catch (e) {
console.error(e);
if (e instanceof TelegramError && e.response.description.includes("Too Many Requests")) {
const delay = Number(e.message.split("retry after ")[1]) || 30;
setTimeout(() => {
return Promise.resolve();
}, delay * 1000);
} else if (e instanceof TelegramError && e.response.description.includes("MESSAGE_TOO_LONG")) {
return Promise.reject(e);
} else {
return Promise.resolve();
}
}
}
export type SendOptions = {
chatId?: number;
message?: Message,
text: string,
parseMode?: ParseMode,
};
export async function oldSendMessage(message: Message, text: string, parseMode?: ParseMode): Promise<Message> {
const response = await bot.sendMessage({
chat_id: message.chat.id,
text: text,
parse_mode: parseMode
});
return Promise.resolve(response);
}
export async function sendMessage(options: SendOptions): Promise<Message> {
const response = await bot.sendMessage({
chat_id: options.chatId ?? options.message?.chat?.id,
text: options.text,
parse_mode: options.parseMode
});
return Promise.resolve(response);
}
export async function replyToMessage(message: Message, text: string, parseMode?: ParseMode): Promise<Message> {
const response = await bot.sendMessage({
chat_id: message.chat.id,
text: text,
reply_parameters: {
message_id: message.message_id
},
parse_mode: parseMode,
});
return Promise.resolve(response);
}
export async function sendErrorPlaceholder(message: Message): Promise<Message> {
return await sendMessage({message: message, text: "Произошла ошибка ⚠️"}).catch(console.error) as Message;
}
export async function initSystemSpecs(): Promise<void> {
try {
const [os, cpu, mem] = await Promise.all([si.osInfo(), si.cpu(), si.mem()]);
setSystemSpecs(`OS: ${os.distro}\n` +
`CPU: ${cpu.manufacturer} ${cpu.brand} ${cpu.physicalCores} cores ${cpu.cores} threads\n` +
`RAM: ${Math.round(mem.total / Math.pow(2, 30))} GB`
);
return Promise.resolve();
} catch (e) {
return Promise.reject(e);
}
}
export function getRandomInt(max: number) {
return Math.floor(Math.random() * Math.floor(max));
}
export function getRangedRandomInt(from: number, to: number): number {
return getRandomInt(to - from) + from;
}
export function randomValue<T>(list: T[]): T {
return list[Math.floor(Math.random() * list.length)];
}
export function chatCommandToString(cmd: ChatCommand): string {
if (!cmd.title && !cmd.description) {
return "";
}
if (cmd.title && cmd.description) {
return `${cmd.title}: ${cmd.description}`;
}
return `${cmd.title ? `${cmd.title}: ` : ""}${cmd.description ? `${cmd.description}` : ""}`;
}
export function fullName(from: User): string {
let fullName = from.first_name;
if (from.last_name) {
fullName += " " + from.last_name;
}
return fullName;
}
export function getUptime(): string {
const processUptime = Math.ceil(process.uptime());
const processDays = Math.floor(processUptime / (3600 * 24));
const processHours = Math.floor((processUptime % (3600 * 24)) / 3600);
const processMinutes = Math.floor((processUptime % 3600) / 60);
const processSeconds = Math.floor(processUptime % 60);
const processUptimeText = `${processDays > 0 ? `${processDays} д. ` : ""}` +
`${processHours > 0 ? `${processHours} ч. ` : ""}` +
`${processMinutes > 0 ? `${processMinutes} м. ` : ""}` +
`${processSeconds > 0 ? `${processSeconds} с.` : ""}`;
const osUptime = Math.ceil(os.uptime());
const osDays = Math.floor(osUptime / (3600 * 24));
const osHours = Math.floor((osUptime % (3600 * 24)) / 3600);
const osMinutes = Math.floor((osUptime % 3600) / 60);
const osSeconds = Math.floor(osUptime % 60);
const osUptimeText = `${osDays > 0 ? `${osDays} д. ` : ""}` +
`${osHours > 0 ? `${osHours} ч. ` : ""}` +
`${osMinutes > 0 ? `${osMinutes} м. ` : ""}` +
`${osSeconds > 0 ? `${osSeconds} с.` : ""}`;
return `${Environment.IS_DOCKER ? "Docker контейнер" : "Процесс"}:\n${processUptimeText}\n\nСистема:\n${osUptimeText}`;
}
export const delay = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const id = setTimeout(resolve, ms);
if (signal) {
const onAbort = () => {
clearTimeout(id);
reject(new DOMException("Aborted", "AbortError"));
};
signal.addEventListener("abort", onAbort, {once: true});
}
});
export function escapeMarkdownV2Text(s: string) {
s = s.replace(/^\*{3,}\s*$/gm, "— — —");
s = s.replace(/^\*\s+(?=\S)/gm, "• ");
s = s.replace(/\*\*(.+?)\*\*/g, "*$1*");
return s;
}
export async function getFileUrl(bot: TelegramBot, fileId: string) {
const file = await bot.getFile({file_id: fileId});
return `https://api.telegram.org/file/bot${bot.botToken}/${file.file_path}`;
}
export async function getChatAvatar(bot: TelegramBot, chatId: number): Promise<Buffer | null> {
try {
const chat = await bot.getChat({chat_id: chatId});
const photo = chat?.photo?.big_file_id || chat?.photo?.small_file_id;
if (!photo) return null;
const url = await getFileUrl(bot, photo);
const res = await axios.get<ArrayBuffer>(url, {responseType: "arraybuffer"});
return Buffer.from(res.data);
} catch {
return null;
}
}
export async function getUserAvatar(bot: TelegramBot, userId: number): Promise<Buffer | null> {
const photos = await bot.getUserProfilePhotos({user_id: userId, limit: 1});
const last: PhotoSize | undefined = photos.photos?.[0]?.[photos.photos[0].length - 1];
if (!last) return null;
const url = await getFileUrl(bot, last.file_id);
const res = await axios.get<ArrayBuffer>(url, {responseType: "arraybuffer"});
return Buffer.from(res.data);
}
export function extractTextMessage(msg: Message, prefix: string = ""): string | null {
let text = (msg?.text ?? msg?.caption ?? "").trim();
if (text.toLowerCase().startsWith(prefix.toLowerCase())) {
text = text.substring(prefix.length);
}
text = text.trim();
if (text.length === 0) return null;
return text;
}
export function extractTextStored(msg: StoredMessage, prefix: string): string {
let text = (msg?.text ?? "").trim();
if (text.toLowerCase().startsWith(prefix.toLowerCase())) {
text = text.substring(prefix.length).trim();
}
return text;
}
export function extractText(text: string, prefix: string): string {
if (text.toLowerCase().startsWith(prefix.toLowerCase())) {
text = text.substring(prefix.length).trim();
}
return text;
}
export async function collectReplyChainText(triggerMsg: Message, prefix: string = Environment.BOT_PREFIX, limit: number = 40, includeTrigger = true): Promise<MessagePart[]> {
const chatId = triggerMsg.chat.id as number;
const parts: MessagePart[] = [];
if (includeTrigger) {
const t = extractTextMessage(triggerMsg, prefix);
if (t) parts.push({
bot: triggerMsg.from.id === botUser.id,
content: triggerMsg.text,
name: triggerMsg.from.first_name
});
}
const first = triggerMsg.reply_to_message;
if (!first) {
return parts;
}
const firstText = extractTextMessage(first, prefix);
if (firstText) parts.push({bot: first.from.id === botUser.id, content: firstText, name: first.from.first_name});
let curId = first.message_id;
while (parts.length < limit) {
const cur = await messageDao.getById({chatId: chatId, id: curId});
const parentId = cur?.replyToMessageId ?? null;
if (!parentId) break;
const parent = await messageDao.getById({chatId: chatId, id: parentId});
if (!parent?.text) break;
const user = await UserStore.get(parent.fromId);
parts.push({
bot: parent.fromId === botUser.id,
content: extractTextStored(parent, prefix),
name: user?.firstName
});
curId = parentId;
}
return parts;
}
export function extractMessagePayload(msg: Message, matchText?: string): string | null {
const payload = (matchText ?? "").trim();
if (payload.length) return payload;
const quote = msg.quote;
if (quote?.text) return quote.text;
const r = msg.reply_to_message;
if (!r) return null;
const t =
(r.text ?? "") ||
(r.caption ?? "") ||
(r.document?.file_name ?? "") ||
"";
return t.trim().length ? t.trim() : null;
}
export function clamp(n: number, a: number, b: number) {
return Math.max(a, Math.min(b, n));
}
export async function waveDistortSharp(
input: Buffer,
amp = 14,
wavelength = 72,
maxSide = 1024
): Promise<Buffer> {
amp = clamp(amp, 2, 60);
wavelength = clamp(wavelength, 16, 300);
const phase1 = Math.random() * Math.PI * 2;
const phase2 = Math.random() * Math.PI * 2;
const amp2 = Math.max(6, Math.floor(amp * 0.6));
const wavelength2 = Math.max(32, Math.floor(wavelength * 1.4));
const {data, info} = await sharp(input)
.resize({width: maxSide, height: maxSide, fit: "inside", withoutEnlargement: true})
.ensureAlpha()
.raw()
.toBuffer({resolveWithObject: true});
const width = info.width!;
const height = info.height!;
const channels = info.channels!; // обычно 4 (RGBA)
const out = Buffer.alloc(data.length);
for (let y = 0; y < height; y++) {
const dx = amp * Math.sin((2 * Math.PI * y) / wavelength + phase1);
for (let x = 0; x < width; x++) {
const dy = amp2 * Math.sin((2 * Math.PI * x) / wavelength2 + phase2);
const sx = Math.round(x + dx);
const sy = Math.round(y + dy);
const di = (y * width + x) * channels;
if (sx < 0 || sx >= width || sy < 0 || sy >= height) {
// прозрачный пиксель
out[di + 0] = 0;
out[di + 1] = 0;
out[di + 2] = 0;
out[di + 3] = 0;
continue;
}
const si = (sy * width + sx) * channels;
data.copy(out, di, si, si + channels);
}
}
return await sharp(out, {raw: {width, height, channels}})
.png()
.toBuffer();
}
export async function downloadTelegramFile(filePath: string): Promise<Buffer> {
const url = `https://api.telegram.org/file/bot${Environment.BOT_TOKEN}/${filePath}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to download file: ${res.status} ${res.statusText}`);
const ab = await res.arrayBuffer();
return Buffer.from(ab);
}
export function extractImageFileId(reply: Message): string | null {
// photo (сжатое)
if (reply.photo?.length) {
return reply.photo[reply.photo.length - 1]!.file_id; // самое большое
}
// document (обычно оригинал)
if (reply.document?.mime_type?.startsWith("image/")) {
return reply.document.file_id;
}
if (reply.sticker?.file_id) {
return reply.sticker.file_id;
}
return null;
}
export async function makeDarkGradientBgFancy(
width: number,
height: number,
seed?: string
): Promise<Buffer> {
const rnd = seed ? seededRand(seed) : Math.random;
const hue1 = Math.floor(rnd() * 360);
const hue2 = (hue1 + 25 + Math.floor(rnd() * 55)) % 360;
const hue3 = (hue2 + 25 + Math.floor(rnd() * 55)) % 360;
const c1 = hslToHex(hue1, 35 + rndInt(rnd, 0, 14), 12 + rndInt(rnd, 0, 6));
const c2 = hslToHex(hue2, 35 + rndInt(rnd, 0, 14), 9 + rndInt(rnd, 0, 5));
const c3 = hslToHex(hue3, 30 + rndInt(rnd, 0, 14), 8 + rndInt(rnd, 0, 5));
// случайный угол градиента
const x1 = rnd(), y1 = rnd();
const x2 = 1 - x1, y2 = 1 - y1;
// мягкое свечение
const glowHue = (hue1 + rndInt(rnd, -25, 25) + 360) % 360;
const glowColor = hslToHex(glowHue, 60, 60);
const glowCx = 0.35 + rnd() * 0.30;
const glowCy = 0.30 + rnd() * 0.35;
const glowR = 0.55 + rnd() * 0.25;
const glowOpacity = 0.14 + rnd() * 0.10;
// виньетка
const vignetteStrength = 0.55 + rnd() * 0.15;
// зерно
const grainSeed = Math.floor(rnd() * 10_000);
const grainAlpha = 0.10 + rnd() * 0.06; // 0.10..0.16
const grainFreq = 0.75 + rnd() * 0.35; // 0.75..1.10
const svg = Buffer.from(`
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<defs>
<linearGradient id="bg" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
<stop offset="0%" stop-color="${c1}"/>
<stop offset="55%" stop-color="${c2}"/>
<stop offset="100%" stop-color="${c3}"/>
</linearGradient>
<radialGradient id="glow" cx="${glowCx}" cy="${glowCy}" r="${glowR}">
<stop offset="0%" stop-color="${glowColor}" stop-opacity="${glowOpacity}"/>
<stop offset="55%" stop-color="${glowColor}" stop-opacity="${glowOpacity * 0.45}"/>
<stop offset="100%" stop-color="${glowColor}" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vig" cx="0.5" cy="0.5" r="0.85">
<stop offset="0%" stop-color="#000" stop-opacity="0"/>
<stop offset="55%" stop-color="#000" stop-opacity="${vignetteStrength * 0.25}"/>
<stop offset="100%" stop-color="#000" stop-opacity="${vignetteStrength}"/>
</radialGradient>
<!-- зерно: чёрный шум с маленькой альфой -->
<filter id="grain" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise"
baseFrequency="${grainFreq}"
numOctaves="2"
seed="${grainSeed}"
result="t"/>
<!-- RGB -> Alpha, RGB = 0 -->
<feColorMatrix in="t" type="matrix"
values="0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0.33 0.33 0.33 0 0"
result="a"/>
<!-- масштабируем альфу (интенсивность зерна) -->
<feComponentTransfer in="a">
<feFuncA type="linear" slope="${grainAlpha}"/>
</feComponentTransfer>
</filter>
</defs>
<rect width="100%" height="100%" fill="url(#bg)"/>
<rect width="100%" height="100%" fill="url(#glow)"/>
<rect width="100%" height="100%" fill="url(#vig)"/>
<rect width="100%" height="100%" filter="url(#grain)"/>
</svg>`);
return sharp(svg)
.resize(width, height)
.blur(0.6) // чуть сгладить градиент/свечение (зерно тоже мягче)
.png()
.toBuffer();
}
export function rndInt(rnd: () => number, min: number, max: number) {
return Math.floor(rnd() * (max - min + 1)) + min;
}
export function seededRand(seed: string): () => number {
// xorshift32 via seed->uint32 (FNV-ish)
let x = 2166136261;
for (let i = 0; i < seed.length; i++) {
x ^= seed.charCodeAt(i);
x = Math.imul(x, 16777619);
}
x >>>= 0;
return () => {
x ^= x << 13;
x >>>= 0;
x ^= x >> 17;
x >>>= 0;
x ^= x << 5;
x >>>= 0;
return (x >>> 0) / 4294967296;
};
}
export function hslToHex(h: number, s: number, l: number) {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) =>
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const r = Math.round(255 * f(0));
const g = Math.round(255 * f(8));
const b = Math.round(255 * f(4));
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
function toHex(v: number) {
return v.toString(16).padStart(2, "0");
}
export function startIntervalEditor(params: {
intervalMs: number;
getText: () => string;
editFn: (text: string) => Promise<void>;
}) {
let lastSent = "";
let stopped = false;
const tick = async () => {
if (stopped) return;
const next = params.getText();
if (!next || next === lastSent) return;
console.log("tick");
try {
await params.editFn(next);
lastSent = next;
} catch (e) {
if ((e?.description ?? e?.message ?? "").includes("message is not modified")) return;
console.error("edit failed:", e);
}
};
const timer = setInterval(async () => await tick(), params.intervalMs);
return {
tick,
stop: async () => {
stopped = true;
clearInterval(timer);
await tick();
},
};
}
export function boolToInt(bool: boolean): number {
return bool ? 1 : 0;
}
type AnyDrizzleTable = {
_: {
columns: Record<string, { name: string }>;
};
};
export function buildExcludedSet<
T extends AnyDrizzleTable,
K extends keyof T["_"]["columns"] & string,
E extends readonly K[] = readonly []
>(table: T, exclude: E = [] as unknown as E): Record<Exclude<K, E[number]>, SQL> {
const cols = orm.getColumns(table as never) as T["_"]["columns"];
const excludeSet = new Set<string>(exclude as readonly string[]);
const entries = Object.keys(cols)
.filter((key) => !excludeSet.has(key))
.map((key) => {
const realName = (cols as unknown)[key].name; // actual DB column name
return [key, sql.raw(`excluded.${realName}`)] as const;
});
return Object.fromEntries(entries) as Record<Exclude<K, E[number]>, SQL>;
}