Files
tg-chat-bot/src/util/utils.ts
T

731 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}