Переписать проект в личный MTProto-юзербот
Смена архитектуры с бизнес-бота на Bot API (команды со слешем) на личный юзербот на GramJS/MTProto с управлением через компаньон-бота. Возможности: - antidelete — кэш входящих в личке, восстановление удалённых «у всех» - onetime — перехват «просмотр один раз» фото/видео до истечения ссылки - локальное хранилище node:sqlite + шифрование AES-256-GCM, чистка по сроку - компаньон-бот (grammY): инлайн-панель, лента с пагинацией, выдача медиа, доступ только владельцу по OWNER_ID Устройство: - core/ — crypto, db, media, loader, registry, logger - userbot/ — MTProto-клиент из строки сессии, подписка на события - modules/ — фичи как модули с хуками setup/onMessage/onEdited/onDeleted - bot/ — компаньон-бот (единственный UI) - scripts/login.js — генерация строки сессии Удалён старый код Bot API (commands/, handlers/, middlewares/, dispatcher, store). Обновлены README и .env.example. Hcrgram/ (сторонний референс) добавлен в .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
-63
@@ -1,63 +0,0 @@
|
||||
import { Bot, GrammyError, HttpError } from 'grammy';
|
||||
|
||||
import { assertConfig, config } from './config.js';
|
||||
import { createDispatcher } from './core/dispatcher.js';
|
||||
import { loadCommands } from './core/loader.js';
|
||||
import { logger } from './core/logger.js';
|
||||
import { connections, handovers } from './core/store.js';
|
||||
import { registerConnectionHandlers } from './handlers/connection.js';
|
||||
import { fallback } from './handlers/fallback.js';
|
||||
import { businessContext } from './middlewares/business.js';
|
||||
import { handover } from './middlewares/handover.js';
|
||||
|
||||
/** Без явного allowed_updates Telegram не пришлёт business-апдейты. */
|
||||
export const ALLOWED_UPDATES = [
|
||||
'message',
|
||||
'edited_message',
|
||||
'callback_query',
|
||||
'business_connection',
|
||||
'business_message',
|
||||
'edited_business_message',
|
||||
'deleted_business_messages',
|
||||
];
|
||||
|
||||
export async function createBot() {
|
||||
assertConfig();
|
||||
await Promise.all([connections.load(), handovers.load()]);
|
||||
|
||||
const registry = await loadCommands();
|
||||
const bot = new Bot(config.token);
|
||||
|
||||
bot.catch(({ error, ctx }) => {
|
||||
const where = `update ${ctx?.update?.update_id}`;
|
||||
if (error instanceof GrammyError) {
|
||||
logger.error(`${where}: Telegram отклонил запрос — ${error.description}`);
|
||||
} else if (error instanceof HttpError) {
|
||||
logger.error(`${where}: сеть недоступна`, error);
|
||||
} else {
|
||||
logger.error(`${where}: необработанная ошибка`, error);
|
||||
}
|
||||
});
|
||||
|
||||
bot.use(businessContext());
|
||||
|
||||
// Подключение/отключение бота к бизнес-аккаунту — до пайплайна сообщений.
|
||||
registerConnectionHandlers(bot);
|
||||
|
||||
bot.on(['message', 'business_message'], handover(), createDispatcher(registry), fallback());
|
||||
|
||||
return { bot, registry };
|
||||
}
|
||||
|
||||
export async function syncCommands(bot, registry) {
|
||||
const commands = registry.forTelegram();
|
||||
await bot.api.setMyCommands(commands);
|
||||
logger.info(`Меню команд обновлено: ${commands.map((c) => `/${c.command}`).join(' ') || '—'}`);
|
||||
}
|
||||
|
||||
export async function shutdown(bot) {
|
||||
logger.info('Останавливаюсь…');
|
||||
await bot.stop();
|
||||
await Promise.all([connections.flush(), handovers.flush()]);
|
||||
logger.info('Состояние сохранено.');
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Bot, InlineKeyboard, InputFile } from 'grammy';
|
||||
|
||||
import { config, isOwner } from '../config.js';
|
||||
import { decrypt } from '../core/crypto.js';
|
||||
import { countCaptures, getCapture, listCaptures } from '../core/db.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { readMedia } from '../core/media.js';
|
||||
|
||||
const PAGE = 5;
|
||||
const KIND_TITLE = { deleted: '🗑 Удалённые', onetime: '👁 Одноразовые' };
|
||||
|
||||
/**
|
||||
* Компаньон-бот — единственный интерфейс юзербота. Всё управление и все
|
||||
* перехваты живут здесь, в личке владельца. Никаких слеш-команд снаружи:
|
||||
* бот отвечает только владельцу (OWNER_ID), любому другому — молчит.
|
||||
*/
|
||||
export function createBot() {
|
||||
const bot = new Bot(config.botToken());
|
||||
|
||||
// Жёсткий барьер: чужие апдейты не обрабатываем вообще.
|
||||
bot.use(async (ctx, next) => {
|
||||
if (isOwner(ctx.from?.id)) return next();
|
||||
logger.debug(`bot: игнорирую чужой апдейт от ${ctx.from?.id}`);
|
||||
});
|
||||
|
||||
bot.command('start', (ctx) => ctx.reply('Панель юзербота.', { reply_markup: mainMenu() }));
|
||||
bot.callbackQuery('home', (ctx) => edit(ctx, 'Панель юзербота.', mainMenu()));
|
||||
|
||||
// Списки перехватов с пагинацией.
|
||||
bot.callbackQuery(/^feed:(\w+):(\d+)$/, async (ctx) => {
|
||||
const [, kind, pageRaw] = ctx.match;
|
||||
await renderFeed(ctx, kind, Number(pageRaw));
|
||||
await ctx.answerCallbackQuery();
|
||||
});
|
||||
|
||||
// Открыть одну запись — с медиа, если оно есть.
|
||||
bot.callbackQuery(/^item:(\d+)$/, async (ctx) => {
|
||||
await renderItem(ctx, Number(ctx.match[1]));
|
||||
await ctx.answerCallbackQuery();
|
||||
});
|
||||
|
||||
bot.catch((err) => logger.error('bot: ошибка обработки', err.error ?? err));
|
||||
return bot;
|
||||
}
|
||||
|
||||
function mainMenu() {
|
||||
return new InlineKeyboard()
|
||||
.text(`🗑 Удалённые (${countCaptures('deleted')})`, 'feed:deleted:0')
|
||||
.row()
|
||||
.text(`👁 Одноразовые (${countCaptures('onetime')})`, 'feed:onetime:0');
|
||||
}
|
||||
|
||||
function feedKeyboard(kind, page, items, total) {
|
||||
const kb = new InlineKeyboard();
|
||||
for (const it of items) {
|
||||
kb.text(`${previewLine(it)}`, `item:${it.id}`).row();
|
||||
}
|
||||
const nav = [];
|
||||
if (page > 0) nav.push(['◀', `feed:${kind}:${page - 1}`]);
|
||||
if ((page + 1) * PAGE < total) nav.push(['▶', `feed:${kind}:${page + 1}`]);
|
||||
nav.forEach(([label, data]) => kb.text(label, data));
|
||||
kb.row().text('🏠 В меню', 'home');
|
||||
return kb;
|
||||
}
|
||||
|
||||
async function renderFeed(ctx, kind, page) {
|
||||
const total = countCaptures(kind);
|
||||
const items = listCaptures({ kind, limit: PAGE, offset: page * PAGE });
|
||||
const title = KIND_TITLE[kind] ?? kind;
|
||||
const body = items.length
|
||||
? `${title} — стр. ${page + 1}/${Math.max(1, Math.ceil(total / PAGE))}`
|
||||
: `${title}: пусто.`;
|
||||
await edit(ctx, body, feedKeyboard(kind, page, items, total));
|
||||
}
|
||||
|
||||
async function renderItem(ctx, id) {
|
||||
const item = getCapture(id);
|
||||
if (!item) return edit(ctx, 'Запись не найдена (возможно, удалена по сроку хранения).', backTo('home'));
|
||||
|
||||
const caption = formatCapture(item);
|
||||
const back = backTo(`feed:${item.kind}:0`);
|
||||
|
||||
if (item.media_path) {
|
||||
try {
|
||||
const buffer = decrypt(await readMedia(item.media_path));
|
||||
await ctx.replyWithDocument(new InputFile(buffer, fileName(item)), { caption, reply_markup: back });
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn('bot: не удалось отдать медиа', err.message);
|
||||
return ctx.reply(`${caption}\n\n⚠️ Медиа не удалось расшифровать.`, { reply_markup: back });
|
||||
}
|
||||
}
|
||||
await edit(ctx, caption, back);
|
||||
}
|
||||
|
||||
// ——— helpers ———
|
||||
|
||||
const backTo = (data) => new InlineKeyboard().text('◀ Назад', data);
|
||||
|
||||
function previewLine(item) {
|
||||
const who = item.sender || 'неизвестно';
|
||||
const what = item.media_kind ? `[${item.media_kind}]` : truncate(item.text ?? '', 30) || '—';
|
||||
return `${who}: ${what}`;
|
||||
}
|
||||
|
||||
function formatCapture(item) {
|
||||
const when = new Date(item.captured_at * 1000).toLocaleString('ru-RU');
|
||||
const lines = [
|
||||
`${KIND_TITLE[item.kind] ?? item.kind}`,
|
||||
`От: ${item.sender || 'неизвестно'}`,
|
||||
`Когда: ${when}`,
|
||||
];
|
||||
if (item.media_kind) lines.push(`Медиа: ${item.media_kind}`);
|
||||
if (item.text) lines.push('', item.text);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function fileName(item) {
|
||||
const ext = { photo: 'jpg', video: 'mp4', voice: 'ogg', gif: 'mp4', audio: 'mp3' }[item.media_kind] ?? 'bin';
|
||||
return `${item.kind}_${item.id}.${ext}`;
|
||||
}
|
||||
|
||||
const truncate = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
||||
|
||||
async function edit(ctx, text, keyboard) {
|
||||
try {
|
||||
if (ctx.callbackQuery) {
|
||||
await ctx.editMessageText(text, { reply_markup: keyboard });
|
||||
} else {
|
||||
await ctx.reply(text, { reply_markup: keyboard });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!String(err.description ?? err).includes('message is not modified')) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Присылает владельцу уведомление о свежем перехвате. Дёргается из модулей
|
||||
* через контекст (ctx.notify) — модуль не знает деталей бота.
|
||||
*/
|
||||
export async function notifyCapture(bot, item) {
|
||||
const kb = new InlineKeyboard().text('Открыть', `item:${item.id}`);
|
||||
const head = item.kind === 'onetime' ? '👁 Одноразовое медиа' : '🗑 Удалённое сообщение';
|
||||
const text = `${head} от ${item.sender || 'неизвестно'}\n${item.media_kind ? `[${item.media_kind}] ` : ''}${truncate(item.text ?? '', 60)}`;
|
||||
try {
|
||||
await bot.api.sendMessage(config.ownerId(), text, { reply_markup: kb });
|
||||
} catch (err) {
|
||||
logger.warn('bot: не удалось уведомить владельца', err.message);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Шаблон команды. Файлы, чьё имя начинается с `_`, загрузчик пропускает,
|
||||
* поэтому этот файл можно скопировать и переименовать — например, в price.js.
|
||||
*
|
||||
* Поля:
|
||||
* command — имя без слэша, обязательное
|
||||
* aliases — дополнительные триггеры (можно кириллицей)
|
||||
* description — строка для /help и setMyCommands
|
||||
* scope — 'all' | 'private' (только личка с ботом) | 'business' (только бизнес-чаты)
|
||||
* adminOnly — доступно владельцу бизнес-аккаунта и ID из ADMIN_IDS
|
||||
* hidden — не показывать в /help и не отправлять в setMyCommands
|
||||
* handler — (ctx, args, { registry, rest }) => Promise<void>
|
||||
*/
|
||||
export default {
|
||||
command: 'template',
|
||||
aliases: [],
|
||||
description: 'Пример команды',
|
||||
scope: 'all',
|
||||
adminOnly: false,
|
||||
hidden: true,
|
||||
|
||||
async handler(ctx, args, { rest }) {
|
||||
// args — массив слов после команды, rest — та же строка целиком.
|
||||
await ctx.reply(args.length ? `Аргументы: ${args.join(', ')}` : `Команда без аргументов (rest: "${rest}")`);
|
||||
},
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { config } from '../../config.js';
|
||||
|
||||
export default {
|
||||
command: 'pause',
|
||||
aliases: ['пауза'],
|
||||
description: 'Остановить автоответы в этом чате: /pause [минуты | off]',
|
||||
scope: 'business',
|
||||
adminOnly: true,
|
||||
|
||||
async handler(ctx, args) {
|
||||
const [arg] = args;
|
||||
|
||||
if (arg === 'off' || arg === 'выкл') {
|
||||
ctx.business.resume();
|
||||
await ctx.reply('Автоответы снова включены в этом чате.');
|
||||
return;
|
||||
}
|
||||
|
||||
const minutes = arg ? Number(arg) : config.handoverPauseMs / 60_000;
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) {
|
||||
await ctx.reply('Нужно число минут или "off". Например: /pause 60');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.business.pause(minutes * 60_000);
|
||||
await ctx.reply(`Молчу в этом чате ${minutes} мин. Вернуть раньше — /pause off`);
|
||||
},
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import { connections } from '../../core/store.js';
|
||||
|
||||
const RIGHTS_TO_SHOW = [
|
||||
['can_reply', 'отвечать'],
|
||||
['can_read_messages', 'читать переписку'],
|
||||
['can_delete_outgoing_messages', 'удалять свои сообщения'],
|
||||
['can_edit_name', 'менять имя'],
|
||||
['can_view_gifts_and_stars', 'видеть подарки и звёзды'],
|
||||
['can_manage_stories', 'управлять историями'],
|
||||
];
|
||||
|
||||
export default {
|
||||
command: 'status',
|
||||
description: 'Состояние бизнес-подключений',
|
||||
scope: 'all',
|
||||
adminOnly: true,
|
||||
|
||||
async handler(ctx) {
|
||||
const all = connections.entries();
|
||||
if (!all.length) {
|
||||
await ctx.reply(
|
||||
'Активных подключений нет.\n\nВключите Business Mode у бота в @BotFather, затем добавьте бота в Settings → Telegram Business → Chatbots.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const blocks = all.map(([id, record]) => {
|
||||
const rights = RIGHTS_TO_SHOW.filter(([key]) => record.rights?.[key]).map(([, label]) => label);
|
||||
|
||||
return [
|
||||
`<b>Подключение</b> <code>${id}</code>`,
|
||||
`владелец: <code>${record.ownerId}</code>${record.ownerUsername ? ` (@${record.ownerUsername})` : ''}`,
|
||||
`активно: ${record.isEnabled ? 'да' : 'нет'}`,
|
||||
`права: ${rights.length ? rights.join(', ') : 'не переданы'}`,
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
await ctx.reply(blocks.join('\n\n'), { parse_mode: 'HTML' });
|
||||
},
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { isOperator } from '../middlewares/business.js';
|
||||
|
||||
export default {
|
||||
command: 'help',
|
||||
aliases: ['помощь', 'справка'],
|
||||
description: 'Список команд',
|
||||
scope: 'all',
|
||||
|
||||
async handler(ctx, args, { registry }) {
|
||||
const operator = isOperator(ctx);
|
||||
|
||||
// Админские команды показываем только тем, кто может их вызвать.
|
||||
const available = registry.visible().filter((cmd) => operator || !cmd.adminOnly);
|
||||
|
||||
const lines = available.map((cmd) => {
|
||||
const aliases = cmd.aliases.length ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : '';
|
||||
const mark = cmd.adminOnly ? ' 🔒' : '';
|
||||
return `/${cmd.command}${aliases} — ${cmd.description}${mark}`;
|
||||
});
|
||||
|
||||
await ctx.reply(lines.length ? `Команды:\n${lines.join('\n')}` : 'Команд пока нет.');
|
||||
},
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
export default {
|
||||
command: 'id',
|
||||
aliases: ['whoami'],
|
||||
description: 'Показать ID пользователя и чата',
|
||||
scope: 'all',
|
||||
|
||||
async handler(ctx) {
|
||||
const lines = [
|
||||
`Ваш ID: <code>${ctx.from?.id}</code>`,
|
||||
`Чат: <code>${ctx.chat?.id}</code> (${ctx.chat?.type})`,
|
||||
];
|
||||
|
||||
if (ctx.business) {
|
||||
lines.push(
|
||||
`Подключение: <code>${ctx.business.connectionId}</code>`,
|
||||
`Владелец: <code>${ctx.business.ownerId ?? 'неизвестен'}</code>`,
|
||||
`Вы владелец: ${ctx.business.isOwner ? 'да' : 'нет'}`,
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
|
||||
},
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
export default {
|
||||
command: 'ping',
|
||||
description: 'Проверка живости и задержки апдейта',
|
||||
scope: 'all',
|
||||
|
||||
async handler(ctx) {
|
||||
// Telegram отдаёт date в секундах, поэтому точность лага — около секунды.
|
||||
const sentAt = (ctx.msg?.date ?? 0) * 1000;
|
||||
const lagMs = sentAt ? Date.now() - sentAt : null;
|
||||
|
||||
const lines = ['pong'];
|
||||
if (lagMs !== null) lines.push(`задержка апдейта: ~${lagMs} мс`);
|
||||
lines.push(`uptime: ${Math.round(process.uptime())} с`);
|
||||
|
||||
await ctx.reply(lines.join('\n'));
|
||||
},
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
export default {
|
||||
command: 'start',
|
||||
aliases: ['старт'],
|
||||
description: 'Приветствие и краткая справка',
|
||||
scope: 'all',
|
||||
|
||||
async handler(ctx, args, { registry }) {
|
||||
const name = ctx.from?.first_name ?? 'там';
|
||||
|
||||
// В бизнес-чате бот пишет от имени владельца, поэтому текст другой:
|
||||
// клиент не должен видеть, что общается с ботом-помощником.
|
||||
if (ctx.business) {
|
||||
await ctx.reply(
|
||||
`Здравствуйте, ${name}! Спасибо за обращение — уже смотрю ваш вопрос и скоро вернусь с ответом.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = registry
|
||||
.visible()
|
||||
.map((cmd) => `/${cmd.command} — ${cmd.description}`)
|
||||
.join('\n');
|
||||
|
||||
await ctx.reply(
|
||||
`Привет, ${name}. Я бот-помощник для Telegram Business.\n\nДоступные команды:\n${commands}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
+56
-18
@@ -1,26 +1,64 @@
|
||||
const parseIds = (raw) =>
|
||||
(raw ?? '')
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
.filter((id) => Number.isInteger(id));
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const config = {
|
||||
token: process.env.BOT_TOKEN ?? '',
|
||||
adminIds: parseIds(process.env.ADMIN_IDS),
|
||||
handoverPauseMs: Number(process.env.HANDOVER_PAUSE_MINUTES ?? 30) * 60_000,
|
||||
logLevel: process.env.LOG_LEVEL ?? 'info',
|
||||
dataDir: new URL('../data/', import.meta.url),
|
||||
const req = (name) => {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} не задан в .env (см. .env.example).`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const num = (name) => {
|
||||
const value = Number(process.env[name]);
|
||||
if (!Number.isInteger(value)) throw new Error(`${name} должен быть целым числом.`);
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Конфиг MTProto-юзербота. api_id/api_hash берутся на https://my.telegram.org,
|
||||
* строка сессии генерируется скриптом login (npm run login) и кладётся в .env.
|
||||
* Всё, что чувствительно (сессия, ключ), живёт только в .env и никуда не уходит.
|
||||
*/
|
||||
export const config = {
|
||||
// Доступ к MTProto.
|
||||
apiId: () => num('API_ID'),
|
||||
apiHash: () => req('API_HASH'),
|
||||
session: process.env.SESSION ?? '',
|
||||
|
||||
// Компаньон-бот (@BotFather) — единственный UI юзербота.
|
||||
botToken: () => req('BOT_TOKEN'),
|
||||
// Telegram id владельца: только он управляет ботом и получает перехваты.
|
||||
ownerId: () => num('OWNER_ID'),
|
||||
|
||||
// Ключ шифрования локального хранилища (32+ байта, hex или ascii).
|
||||
encryptionKey: () => req('ENCRYPTION_KEY'),
|
||||
|
||||
// Сколько дней держать перехваченное, прежде чем чистить (0 — бессрочно).
|
||||
retentionDays: Number(process.env.RETENTION_DAYS ?? 30),
|
||||
|
||||
// Качать ли медиа входящих сразу (нужно, чтобы восстанавливать удалённые
|
||||
// картинки/видео — при удалении ссылка на файл уже мертва). Ест диск.
|
||||
cacheMedia: process.env.CACHE_MEDIA !== '0',
|
||||
|
||||
dataDir: new URL('../data/', import.meta.url),
|
||||
mediaDir: new URL('../data/media/', import.meta.url),
|
||||
logLevel: process.env.LOG_LEVEL ?? 'info',
|
||||
};
|
||||
|
||||
export const dataPath = () => fileURLToPath(config.dataDir);
|
||||
export const mediaPath = () => fileURLToPath(config.mediaDir);
|
||||
|
||||
/** Проверяем всё, что нужно для старта, одним махом — понятная ошибка вместо падения в рантайме. */
|
||||
export function assertConfig() {
|
||||
if (!config.token) {
|
||||
throw new Error('BOT_TOKEN не задан. Скопируйте .env.example в .env и вставьте токен от @BotFather.');
|
||||
config.apiId();
|
||||
config.apiHash();
|
||||
config.botToken();
|
||||
config.ownerId();
|
||||
const key = config.encryptionKey();
|
||||
if (Buffer.from(key, key.match(/^[0-9a-f]+$/i) ? 'hex' : 'utf8').length < 32) {
|
||||
throw new Error('ENCRYPTION_KEY должен быть не короче 32 байт (64 hex-символа).');
|
||||
}
|
||||
if (!Number.isFinite(config.handoverPauseMs) || config.handoverPauseMs < 0) {
|
||||
throw new Error('HANDOVER_PAUSE_MINUTES должен быть неотрицательным числом.');
|
||||
if (!config.session) {
|
||||
throw new Error('SESSION пуст. Выполните: npm run login — и вставьте строку в .env.');
|
||||
}
|
||||
}
|
||||
|
||||
export const isAdmin = (userId) => config.adminIds.includes(userId);
|
||||
export const isOwner = (id) => Number(id) === config.ownerId();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
||||
|
||||
import { config } from '../config.js';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
let keyCache = null;
|
||||
|
||||
/**
|
||||
* Ключ из ENCRYPTION_KEY. Принимаем как 64-символьный hex (готовые 32 байта),
|
||||
* так и произвольную парольную фразу — её растягиваем scrypt до 32 байт.
|
||||
*/
|
||||
function getKey() {
|
||||
if (keyCache) return keyCache;
|
||||
const raw = config.encryptionKey();
|
||||
if (/^[0-9a-f]{64}$/i.test(raw)) {
|
||||
keyCache = Buffer.from(raw, 'hex');
|
||||
} else {
|
||||
keyCache = scryptSync(raw, 'telegrambusiness/v1', 32);
|
||||
}
|
||||
return keyCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Шифрует буфер. Формат на выходе: [iv(12)][tag(16)][ciphertext].
|
||||
* Так весь артефакт самодостаточен — расшифровка не требует внешних метаданных.
|
||||
*/
|
||||
export function encrypt(plaintext) {
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const data = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), data]);
|
||||
}
|
||||
|
||||
export function decrypt(payload) {
|
||||
const iv = payload.subarray(0, IV_LEN);
|
||||
const tag = payload.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const data = payload.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]);
|
||||
}
|
||||
|
||||
export const encryptString = (text) => encrypt(Buffer.from(text, 'utf8')).toString('base64');
|
||||
export const decryptString = (b64) => decrypt(Buffer.from(b64, 'base64')).toString('utf8');
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { config, dataPath } from '../config.js';
|
||||
import { encryptString, decryptString } from './crypto.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
let db = null;
|
||||
|
||||
/**
|
||||
* Локальный индекс на node:sqlite. Тексты храним зашифрованными (AES-GCM),
|
||||
* бинарные медиа лежат отдельными файлами в data/media, здесь только путь.
|
||||
*/
|
||||
export function openDb() {
|
||||
if (db) return db;
|
||||
mkdirSync(dataPath(), { recursive: true });
|
||||
db = new DatabaseSync(`${dataPath()}/userbot.db`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
msg_id INTEGER PRIMARY KEY,
|
||||
chat_id TEXT,
|
||||
sender_id TEXT,
|
||||
sender TEXT,
|
||||
text_enc TEXT,
|
||||
media_kind TEXT,
|
||||
media_path TEXT,
|
||||
created_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS captures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT, -- 'deleted' | 'onetime'
|
||||
chat_id TEXT,
|
||||
sender_id TEXT,
|
||||
sender TEXT,
|
||||
text_enc TEXT,
|
||||
media_kind TEXT,
|
||||
media_path TEXT,
|
||||
captured_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_captures_time ON captures(captured_at DESC);
|
||||
`);
|
||||
logger.debug('sqlite: база готова');
|
||||
return db;
|
||||
}
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
/** Кладём каждое входящее в кольцевой кэш — источник для восстановления удалённых. */
|
||||
export function cacheMessage({ msgId, chatId, senderId, sender, text, mediaKind, mediaPath }) {
|
||||
openDb()
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO messages
|
||||
(msg_id, chat_id, sender_id, sender, text_enc, media_kind, media_path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
msgId,
|
||||
String(chatId ?? ''),
|
||||
String(senderId ?? ''),
|
||||
sender ?? '',
|
||||
text ? encryptString(text) : null,
|
||||
mediaKind ?? null,
|
||||
mediaPath ?? null,
|
||||
now(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCachedMessage(msgId) {
|
||||
const row = openDb().prepare('SELECT * FROM messages WHERE msg_id = ?').get(msgId);
|
||||
return row ? decodeRow(row) : null;
|
||||
}
|
||||
|
||||
/** Записываем факт перехвата (удаление или one-time) — это и есть лента в боте. */
|
||||
export function saveCapture(kind, data) {
|
||||
const info = openDb()
|
||||
.prepare(
|
||||
`INSERT INTO captures
|
||||
(kind, chat_id, sender_id, sender, text_enc, media_kind, media_path, captured_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
kind,
|
||||
String(data.chatId ?? ''),
|
||||
String(data.senderId ?? ''),
|
||||
data.sender ?? '',
|
||||
data.text ? encryptString(data.text) : null,
|
||||
data.mediaKind ?? null,
|
||||
data.mediaPath ?? null,
|
||||
now(),
|
||||
);
|
||||
return Number(info.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function listCaptures({ kind, limit = 10, offset = 0 } = {}) {
|
||||
const where = kind ? 'WHERE kind = ?' : '';
|
||||
const args = kind ? [kind, limit, offset] : [limit, offset];
|
||||
return openDb()
|
||||
.prepare(`SELECT * FROM captures ${where} ORDER BY captured_at DESC LIMIT ? OFFSET ?`)
|
||||
.all(...args)
|
||||
.map(decodeRow);
|
||||
}
|
||||
|
||||
export function getCapture(id) {
|
||||
const row = openDb().prepare('SELECT * FROM captures WHERE id = ?').get(id);
|
||||
return row ? decodeRow(row) : null;
|
||||
}
|
||||
|
||||
export function countCaptures(kind) {
|
||||
const where = kind ? 'WHERE kind = ?' : '';
|
||||
const args = kind ? [kind] : [];
|
||||
return openDb().prepare(`SELECT COUNT(*) AS n FROM captures ${where}`).get(...args).n;
|
||||
}
|
||||
|
||||
/** Чистка по retentionDays — вызывается по таймеру из index.js. */
|
||||
export function purgeOld() {
|
||||
if (!config.retentionDays) return { messages: 0, captures: 0 };
|
||||
const cutoff = now() - config.retentionDays * 86400;
|
||||
const m = openDb().prepare('DELETE FROM messages WHERE created_at < ?').run(cutoff);
|
||||
const c = openDb().prepare('DELETE FROM captures WHERE captured_at < ?').run(cutoff);
|
||||
return { messages: Number(m.changes), captures: Number(c.changes) };
|
||||
}
|
||||
|
||||
function decodeRow(row) {
|
||||
return {
|
||||
...row,
|
||||
text: row.text_enc ? safeDecrypt(row.text_enc) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function safeDecrypt(value) {
|
||||
try {
|
||||
return decryptString(value);
|
||||
} catch (err) {
|
||||
logger.warn('sqlite: не удалось расшифровать текст записи', err.message);
|
||||
return '[не удалось расшифровать]';
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { isAdmin } from '../config.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Разбирает команду из ctx.msg — то есть и из `message`, и из `business_message`.
|
||||
* Родной bot.command() в grammY смотрит только на message/channel_post,
|
||||
* поэтому в бизнес-чатах он молчит.
|
||||
*/
|
||||
export function parseCommand(ctx) {
|
||||
const msg = ctx.msg;
|
||||
const text = msg?.text ?? msg?.caption;
|
||||
if (!text || !text.startsWith('/')) return null;
|
||||
|
||||
const entities = msg.entities ?? msg.caption_entities ?? [];
|
||||
const entity = entities.find((e) => e.type === 'bot_command' && e.offset === 0);
|
||||
|
||||
// Латинские команды Telegram размечает сам. Для алиасов вроде "/старт"
|
||||
// разметки не будет, поэтому режем текст руками.
|
||||
const rawLength = entity?.length ?? (text.split(/\s/, 1)[0]?.length ?? 0);
|
||||
const raw = text.slice(1, rawLength);
|
||||
if (!raw) return null;
|
||||
|
||||
const [name, target] = raw.split('@');
|
||||
if (target && target.toLowerCase() !== ctx.me?.username?.toLowerCase()) return null;
|
||||
|
||||
const rest = text.slice(rawLength).trim();
|
||||
return {
|
||||
name: name.toLowerCase(),
|
||||
rest,
|
||||
args: rest.length ? rest.split(/\s+/) : [],
|
||||
};
|
||||
}
|
||||
|
||||
const inScope = (scope, ctx) => {
|
||||
if (scope === 'all') return true;
|
||||
if (scope === 'business') return Boolean(ctx.businessConnectionId);
|
||||
return !ctx.businessConnectionId && ctx.chat?.type === 'private';
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('./registry.js').CommandRegistry} registry
|
||||
*/
|
||||
export function createDispatcher(registry) {
|
||||
return async (ctx, next) => {
|
||||
// На правках сообщений команды не выполняем, иначе один /start сработает дважды.
|
||||
if (ctx.editedMessage || ctx.editedBusinessMessage) return next();
|
||||
|
||||
const parsed = parseCommand(ctx);
|
||||
if (!parsed) return next();
|
||||
|
||||
const command = registry.resolve(parsed.name);
|
||||
if (!command) return next();
|
||||
|
||||
if (!inScope(command.scope, ctx)) {
|
||||
logger.debug(`/${parsed.name}: вне scope "${command.scope}", пропускаем`);
|
||||
return next();
|
||||
}
|
||||
|
||||
const allowed = !command.adminOnly || isAdmin(ctx.from?.id) || ctx.business?.isOwner === true;
|
||||
if (!allowed) {
|
||||
logger.warn(`/${parsed.name}: отказано пользователю ${ctx.from?.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`/${command.command} от ${ctx.from?.id}${ctx.businessConnectionId ? ' (business)' : ''}`);
|
||||
await command.handler(ctx, parsed.args, { registry, rest: parsed.rest });
|
||||
};
|
||||
}
|
||||
+11
-11
@@ -3,30 +3,30 @@ import { join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { CommandRegistry } from './registry.js';
|
||||
import { ModuleRegistry } from './registry.js';
|
||||
|
||||
const COMMANDS_DIR = fileURLToPath(new URL('../commands/', import.meta.url));
|
||||
const MODULES_DIR = fileURLToPath(new URL('../modules/', import.meta.url));
|
||||
|
||||
/**
|
||||
* Забирает все *.js из src/commands (включая подпапки) и складывает в реестр.
|
||||
* Забирает все *.js из src/modules (включая подпапки) в реестр.
|
||||
* Файлы, начинающиеся с `_`, игнорируются — удобно для черновиков и хелперов.
|
||||
*/
|
||||
export async function loadCommands(dir = COMMANDS_DIR) {
|
||||
const registry = new CommandRegistry();
|
||||
export async function loadModules(dir = MODULES_DIR) {
|
||||
const registry = new ModuleRegistry();
|
||||
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
||||
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.js') && !entry.name.startsWith('_'))
|
||||
.map((entry) => join(entry.parentPath ?? entry.path, entry.name))
|
||||
.filter((e) => e.isFile() && e.name.endsWith('.js') && !e.name.startsWith('_'))
|
||||
.map((e) => join(e.parentPath ?? e.path, e.name))
|
||||
.sort();
|
||||
|
||||
for (const file of files) {
|
||||
const source = relative(dir, file).split(sep).join('/');
|
||||
const module = await import(pathToFileURL(file).href);
|
||||
registry.register(module.default, source);
|
||||
logger.debug(`команда загружена: ${source}`);
|
||||
const mod = await import(pathToFileURL(file).href);
|
||||
registry.register(mod.default, source);
|
||||
logger.debug(`модуль загружен: ${source}`);
|
||||
}
|
||||
|
||||
logger.info(`Загружено команд: ${registry.list().length}`);
|
||||
logger.info(`Загружено модулей: ${registry.list().length}`);
|
||||
return registry;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { config } from '../config.js';
|
||||
|
||||
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
||||
const threshold = LEVELS[config.logLevel] ?? LEVELS.info;
|
||||
const threshold = () => LEVELS[config.logLevel] ?? LEVELS.info;
|
||||
|
||||
const write = (level, stream, args) => {
|
||||
if (LEVELS[level] < threshold) return;
|
||||
if (LEVELS[level] < threshold()) return;
|
||||
stream(`[${new Date().toISOString()}] ${level.toUpperCase()}`, ...args);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { config, mediaPath } from '../config.js';
|
||||
import { encrypt } from './crypto.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/** Определяем тип медиа по объекту сообщения GramJS. */
|
||||
export function detectMedia(message) {
|
||||
if (!message?.media) return null;
|
||||
if (message.photo) return 'photo';
|
||||
if (message.video) return 'video';
|
||||
if (message.voice) return 'voice';
|
||||
if (message.videoNote) return 'video_note';
|
||||
if (message.gif) return 'gif';
|
||||
if (message.audio) return 'audio';
|
||||
if (message.sticker) return 'sticker';
|
||||
if (message.document) return 'document';
|
||||
return 'media';
|
||||
}
|
||||
|
||||
/**
|
||||
* Одноразовые медиа помечены ttlSeconds на самом медиа. Такие файлы Telegram
|
||||
* стирает у клиента после просмотра — их важно скачать в момент прихода.
|
||||
*/
|
||||
export function isOneTime(message) {
|
||||
const media = message?.media;
|
||||
const ttl = media?.ttlSeconds ?? media?.photo?.ttlSeconds ?? media?.document?.ttlSeconds;
|
||||
return Boolean(ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивает медиа сообщения, шифрует и кладёт на диск. Возвращает
|
||||
* { kind, path } либо null. Имя файла не раскрывает содержимого.
|
||||
*/
|
||||
export async function downloadEncrypted(client, message, tag = 'm') {
|
||||
const kind = detectMedia(message);
|
||||
if (!kind) return null;
|
||||
|
||||
try {
|
||||
const buffer = await client.downloadMedia(message, {});
|
||||
if (!buffer || !buffer.length) return { kind, path: null };
|
||||
|
||||
mkdirSync(mediaPath(), { recursive: true });
|
||||
const name = `${tag}_${message.id ?? 'x'}_${buffer.length}.enc`;
|
||||
const full = fileURLToPath(new URL(name, config.mediaDir));
|
||||
await writeFile(full, encrypt(buffer));
|
||||
logger.debug(`media: сохранено ${kind} -> ${name} (${buffer.length} б)`);
|
||||
return { kind, path: name };
|
||||
} catch (err) {
|
||||
logger.warn(`media: не удалось скачать ${kind}:`, err.message);
|
||||
return { kind, path: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Читает зашифрованный медиа-файл с диска по имени (как хранится в БД). */
|
||||
export function readMedia(name) {
|
||||
return readFile(fileURLToPath(new URL(name, config.mediaDir)));
|
||||
}
|
||||
+66
-63
@@ -1,84 +1,87 @@
|
||||
export const SCOPES = ['all', 'private', 'business'];
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/** Telegram принимает в setMyCommands только [a-z0-9_]{1,32} */
|
||||
const TELEGRAM_COMMAND = /^[a-z0-9_]{1,32}$/;
|
||||
|
||||
export class CommandRegistry {
|
||||
/** @type {Map<string, object>} триггер (имя или алиас) -> команда */
|
||||
#triggers = new Map();
|
||||
/** @type {object[]} */
|
||||
#commands = [];
|
||||
/**
|
||||
* Реестр модулей юзербота. Модуль — это файл в src/modules, который экспортирует
|
||||
* по умолчанию объект вида:
|
||||
*
|
||||
* export default {
|
||||
* name: 'antidelete',
|
||||
* description: 'Ловит удалённые сообщения',
|
||||
* setup(ctx) { ... }, // однократная инициализация
|
||||
* onMessage(message, ctx) { ... }, // новое входящее (личные чаты)
|
||||
* onDeleted(event, ctx) { ... }, // событие удаления
|
||||
* onEdited(message, ctx) { ... }, // правка сообщения
|
||||
* panel: { title, id, render, ... }, // раздел в компаньон-боте (опц.)
|
||||
* }
|
||||
*
|
||||
* Обработчики опциональны — модуль объявляет только то, что ему нужно.
|
||||
*/
|
||||
export class ModuleRegistry {
|
||||
#modules = [];
|
||||
#byName = new Map();
|
||||
|
||||
register(definition, source = '<inline>') {
|
||||
const command = normalize(definition, source);
|
||||
|
||||
for (const trigger of [command.command, ...command.aliases]) {
|
||||
const taken = this.#triggers.get(trigger);
|
||||
if (taken) {
|
||||
throw new Error(
|
||||
`Конфликт триггеров: "${trigger}" из ${source} уже занят командой "${taken.command}" (${taken.source}).`,
|
||||
);
|
||||
}
|
||||
this.#triggers.set(trigger, command);
|
||||
const module = normalize(definition, source);
|
||||
if (this.#byName.has(module.name)) {
|
||||
throw new Error(`Модуль "${module.name}" уже зарегистрирован (${source}).`);
|
||||
}
|
||||
|
||||
this.#commands.push(command);
|
||||
return command;
|
||||
this.#byName.set(module.name, module);
|
||||
this.#modules.push(module);
|
||||
return module;
|
||||
}
|
||||
|
||||
resolve(trigger) {
|
||||
return this.#triggers.get(trigger.toLowerCase());
|
||||
}
|
||||
|
||||
/** Все команды в порядке регистрации. */
|
||||
list() {
|
||||
return [...this.#commands];
|
||||
return [...this.#modules];
|
||||
}
|
||||
|
||||
/** Что показываем в /help. */
|
||||
visible() {
|
||||
return this.#commands.filter((cmd) => !cmd.hidden);
|
||||
get(name) {
|
||||
return this.#byName.get(name);
|
||||
}
|
||||
|
||||
/** Что уходит в setMyCommands: без скрытых, админских и нелатинских алиасов. */
|
||||
forTelegram() {
|
||||
return this.#commands
|
||||
.filter((cmd) => !cmd.hidden && !cmd.adminOnly && TELEGRAM_COMMAND.test(cmd.command))
|
||||
.map((cmd) => ({ command: cmd.command, description: cmd.description }));
|
||||
/** Модули, у которых объявлен нужный хук — чтобы не дёргать пустышки. */
|
||||
withHook(hook) {
|
||||
return this.#modules.filter((m) => typeof m[hook] === 'function');
|
||||
}
|
||||
|
||||
panels() {
|
||||
return this.#modules.filter((m) => m.panel).map((m) => ({ ...m.panel, module: m.name }));
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(definition, source) {
|
||||
if (!definition || typeof definition !== 'object') {
|
||||
throw new Error(`${source}: файл команды должен экспортировать объект по умолчанию.`);
|
||||
function normalize(def, source) {
|
||||
if (!def || typeof def !== 'object') {
|
||||
throw new Error(`${source}: модуль должен экспортировать объект по умолчанию.`);
|
||||
}
|
||||
|
||||
const { command, aliases = [], description = '', scope = 'all', adminOnly = false, hidden = false, handler } = definition;
|
||||
|
||||
if (typeof command !== 'string' || !command.trim()) {
|
||||
throw new Error(`${source}: не задано поле "command".`);
|
||||
if (typeof def.name !== 'string' || !def.name.trim()) {
|
||||
throw new Error(`${source}: у модуля не задано поле "name".`);
|
||||
}
|
||||
if (command.startsWith('/')) {
|
||||
throw new Error(`${source}: имя команды указывается без слэша — "${command.slice(1)}", а не "${command}".`);
|
||||
for (const hook of ['setup', 'onMessage', 'onDeleted', 'onEdited']) {
|
||||
if (def[hook] !== undefined && typeof def[hook] !== 'function') {
|
||||
throw new Error(`${source}: "${hook}" должен быть функцией.`);
|
||||
}
|
||||
}
|
||||
if (typeof handler !== 'function') {
|
||||
throw new Error(`${source}: поле "handler" должно быть функцией.`);
|
||||
}
|
||||
if (!SCOPES.includes(scope)) {
|
||||
throw new Error(`${source}: неизвестный scope "${scope}". Допустимо: ${SCOPES.join(', ')}.`);
|
||||
}
|
||||
if (!Array.isArray(aliases) || aliases.some((alias) => typeof alias !== 'string')) {
|
||||
throw new Error(`${source}: "aliases" должен быть массивом строк.`);
|
||||
}
|
||||
|
||||
return {
|
||||
command: command.toLowerCase(),
|
||||
aliases: aliases.map((alias) => alias.toLowerCase()),
|
||||
description,
|
||||
scope,
|
||||
adminOnly: Boolean(adminOnly),
|
||||
hidden: Boolean(hidden),
|
||||
handler,
|
||||
name: def.name,
|
||||
description: def.description ?? '',
|
||||
setup: def.setup,
|
||||
onMessage: def.onMessage,
|
||||
onDeleted: def.onDeleted,
|
||||
onEdited: def.onEdited,
|
||||
panel: def.panel,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогоняет хук по всем модулям, где он объявлен. Ошибка одного модуля
|
||||
* не роняет остальные — логируем и идём дальше.
|
||||
*/
|
||||
export async function runHook(registry, hook, payload, ctx) {
|
||||
for (const module of registry.withHook(hook)) {
|
||||
try {
|
||||
await module[hook](payload, ctx);
|
||||
} catch (err) {
|
||||
logger.error(`Модуль "${module.name}", хук ${hook}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { config } from '../config.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Простейшее JSON-хранилище: держим состояние в памяти, на диск пишем
|
||||
* дебаунсом через временный файл. Для продакшена меняется на Redis/Postgres,
|
||||
* интерфейс (get/set/delete) при этом остаётся тем же.
|
||||
*/
|
||||
export class JsonStore {
|
||||
#path;
|
||||
#data = new Map();
|
||||
#flushTimer = null;
|
||||
#writing = Promise.resolve();
|
||||
|
||||
constructor(fileName) {
|
||||
this.#path = fileURLToPath(new URL(fileName, config.dataDir));
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const raw = await readFile(this.#path, 'utf8');
|
||||
this.#data = new Map(Object.entries(JSON.parse(raw)));
|
||||
logger.debug(`store ${this.#path}: загружено записей — ${this.#data.size}`);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
logger.debug(`store ${this.#path}: файла нет, начинаем с пустого состояния`);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this.#data.get(String(key));
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.#data.has(String(key));
|
||||
}
|
||||
|
||||
entries() {
|
||||
return [...this.#data.entries()];
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
this.#data.set(String(key), value);
|
||||
this.#scheduleFlush();
|
||||
return value;
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
const existed = this.#data.delete(String(key));
|
||||
if (existed) this.#scheduleFlush();
|
||||
return existed;
|
||||
}
|
||||
|
||||
#scheduleFlush() {
|
||||
if (this.#flushTimer) return;
|
||||
this.#flushTimer = setTimeout(() => {
|
||||
this.#flushTimer = null;
|
||||
this.flush().catch((err) => logger.error('store: не удалось записать файл', err));
|
||||
}, 300);
|
||||
this.#flushTimer.unref?.();
|
||||
}
|
||||
|
||||
async flush() {
|
||||
this.#writing = this.#writing.then(async () => {
|
||||
const payload = JSON.stringify(Object.fromEntries(this.#data), null, 2);
|
||||
const tmp = `${this.#path}.tmp`;
|
||||
await mkdir(fileURLToPath(config.dataDir), { recursive: true });
|
||||
await writeFile(tmp, payload, 'utf8');
|
||||
await rename(tmp, this.#path);
|
||||
});
|
||||
return this.#writing;
|
||||
}
|
||||
}
|
||||
|
||||
/** business_connection_id -> { ownerId, userChatId, isEnabled, rights, updatedAt } */
|
||||
export const connections = new JsonStore('connections.json');
|
||||
|
||||
/** `${connectionId}:${chatId}` -> timestamp, до которого автоответы выключены */
|
||||
export const handovers = new JsonStore('handovers.json');
|
||||
@@ -1,33 +0,0 @@
|
||||
import { logger } from '../core/logger.js';
|
||||
import { connections } from '../core/store.js';
|
||||
import { saveConnection } from '../middlewares/business.js';
|
||||
|
||||
/**
|
||||
* business_connection приходит, когда владелец подключает бота, меняет права
|
||||
* или отключает его. Здесь единственное место, где мы узнаём connection.id, —
|
||||
* без него нельзя отправить ни одного сообщения, поэтому пишем его в хранилище.
|
||||
*/
|
||||
export function registerConnectionHandlers(bot) {
|
||||
bot.on('business_connection', async (ctx) => {
|
||||
const connection = ctx.businessConnection;
|
||||
|
||||
if (connection.is_enabled) {
|
||||
saveConnection(connection);
|
||||
logger.info(`Бот подключён к аккаунту ${connection.user.id} (${connection.id})`);
|
||||
|
||||
// user_chat_id — личный чат с владельцем, туда пишем без business_connection_id.
|
||||
await ctx.api.sendMessage(
|
||||
connection.user_chat_id,
|
||||
'Бот подключён к вашему бизнес-аккаунту. Проверить права — /status, остановить автоответы в конкретном чате — /pause.',
|
||||
).catch((err) => logger.warn('Не удалось написать владельцу', err));
|
||||
return;
|
||||
}
|
||||
|
||||
connections.delete(connection.id);
|
||||
logger.info(`Подключение ${connection.id} отключено владельцем ${connection.user.id}`);
|
||||
});
|
||||
|
||||
bot.on('deleted_business_messages', (ctx) => {
|
||||
logger.debug(`Удалено сообщений: ${ctx.deletedBusinessMessages.message_ids.length}`);
|
||||
});
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
/**
|
||||
* Всё, что не команда. Сюда же вешается автоответчик, FAQ или переход в LLM.
|
||||
* Вызывается последним в цепочке, поэтому next() уже не нужен.
|
||||
*/
|
||||
export function fallback() {
|
||||
return async (ctx) => {
|
||||
const text = ctx.msg?.text ?? ctx.msg?.caption ?? '';
|
||||
|
||||
if (ctx.business) {
|
||||
logger.debug(`Бизнес-чат ${ctx.chat?.id}: сообщение без команды`);
|
||||
|
||||
// Отметку «прочитано» ставим только если владелец выдал право читать чат.
|
||||
if (ctx.business.rights?.can_read_messages && ctx.msgId) {
|
||||
await ctx.api
|
||||
.readBusinessMessage(ctx.business.connectionId, ctx.chat.id, ctx.msgId)
|
||||
.catch((err) => logger.debug('readBusinessMessage не сработал', err.description ?? err));
|
||||
}
|
||||
|
||||
await ctx.reply('Принял ваше сообщение, скоро ответим.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.startsWith('/')) {
|
||||
await ctx.reply('Не понял. Посмотрите /help — там список команд.');
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply(`Команда ${text.split(/\s/)[0]} не найдена. Список — /help`);
|
||||
};
|
||||
}
|
||||
+56
-28
@@ -1,34 +1,62 @@
|
||||
import { GrammyError } from 'grammy';
|
||||
|
||||
import { ALLOWED_UPDATES, createBot, shutdown, syncCommands } from './bot.js';
|
||||
import { assertConfig, config } from './config.js';
|
||||
import { createBot } from './bot/panel.js';
|
||||
import { openDb, purgeOld } from './core/db.js';
|
||||
import { loadModules } from './core/loader.js';
|
||||
import { logger } from './core/logger.js';
|
||||
import { runHook } from './core/registry.js';
|
||||
import { createUserbot, startUserbot } from './userbot/client.js';
|
||||
import { createContext } from './userbot/context.js';
|
||||
import { wireEvents } from './userbot/events.js';
|
||||
|
||||
const { bot, registry } = await createBot();
|
||||
// 1. Конфиг и БД — падаем понятной ошибкой, если что-то не задано.
|
||||
try {
|
||||
assertConfig();
|
||||
} catch (err) {
|
||||
logger.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
openDb();
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, () => {
|
||||
shutdown(bot).then(
|
||||
() => process.exit(0),
|
||||
(err) => {
|
||||
logger.error('Ошибка при остановке', err);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
// 2. Юзербот (MTProto): подключаемся уже готовой сессией.
|
||||
const client = createUserbot();
|
||||
let me;
|
||||
try {
|
||||
me = await startUserbot(client);
|
||||
} catch (err) {
|
||||
logger.error('Не удалось подключить юзербот. Проверьте SESSION/API_ID/API_HASH.', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await bot.start({
|
||||
allowed_updates: ALLOWED_UPDATES,
|
||||
// Первый апдейт приходит уже после getMe, поэтому меню обновляем здесь.
|
||||
onStart: async ({ username }) => {
|
||||
await syncCommands(bot, registry).catch((err) => logger.warn('setMyCommands не прошёл', err));
|
||||
logger.info(`@${username} запущен. Апдейты: ${ALLOWED_UPDATES.join(', ')}`);
|
||||
},
|
||||
// 3. Компаньон-бот (единственный UI) — запускаем в фоне.
|
||||
const bot = createBot();
|
||||
bot.start({ onStart: ({ username }) => logger.info(`Панель поднята: @${username}`) }).catch((err) => {
|
||||
logger.error('Компаньон-бот не запустился (проверьте BOT_TOKEN):', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 4. Модули: грузим и раздаём им события юзербота.
|
||||
const registry = await loadModules();
|
||||
const ctx = createContext({ client, bot, me, registry });
|
||||
await runHook(registry, 'setup', undefined, ctx);
|
||||
wireEvents(client, registry, ctx);
|
||||
|
||||
logger.info(`Готово. Активных модулей: ${registry.list().map((m) => m.name).join(', ')}`);
|
||||
|
||||
// 5. Периодическая чистка по сроку хранения.
|
||||
if (config.retentionDays > 0) {
|
||||
const timer = setInterval(() => {
|
||||
const { messages, captures } = purgeOld();
|
||||
if (messages || captures) logger.info(`Чистка: удалено сообщений ${messages}, перехватов ${captures}`);
|
||||
}, 6 * 3600 * 1000);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
// 6. Аккуратная остановка.
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, async () => {
|
||||
logger.info('Останавливаюсь…');
|
||||
await bot.stop().catch(() => {});
|
||||
await client.disconnect().catch(() => {});
|
||||
process.exit(0);
|
||||
});
|
||||
} catch (err) {
|
||||
// bot.catch() ловит ошибки апдейтов, но не падения на старте.
|
||||
if (err instanceof GrammyError && err.error_code === 401) {
|
||||
logger.error('Telegram не принял токен (401). Проверьте BOT_TOKEN в .env.');
|
||||
} else {
|
||||
logger.error('Не удалось запустить б�
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { isAdmin } from '../config.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { connections, handovers } from '../core/store.js';
|
||||
|
||||
export const pauseKey = (connectionId, chatId) => `${connectionId}:${chatId}`;
|
||||
|
||||
export const isPaused = (connectionId, chatId) => {
|
||||
const until = handovers.get(pauseKey(connectionId, chatId));
|
||||
if (!until) return false;
|
||||
if (until > Date.now()) return true;
|
||||
handovers.delete(pauseKey(connectionId, chatId));
|
||||
return false;
|
||||
};
|
||||
|
||||
export function saveConnection(connection) {
|
||||
return connections.set(connection.id, {
|
||||
ownerId: connection.user.id,
|
||||
ownerUsername: connection.user.username ?? null,
|
||||
userChatId: connection.user_chat_id,
|
||||
isEnabled: connection.is_enabled ?? true,
|
||||
rights: connection.rights ?? null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Кладёт в ctx.business всё, что нужно обработчикам: кто владелец аккаунта,
|
||||
* пишет ли он сам прямо сейчас, какие права выданы боту.
|
||||
*
|
||||
* Данные о подключении приходят один раз в апдейте business_connection,
|
||||
* поэтому храним их и при промахе кэша дотягиваем через getBusinessConnection.
|
||||
*/
|
||||
export function businessContext() {
|
||||
return async (ctx, next) => {
|
||||
const connectionId = ctx.businessConnectionId;
|
||||
if (!connectionId) {
|
||||
ctx.business = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
let record = connections.get(connectionId);
|
||||
if (!record) {
|
||||
try {
|
||||
record = saveConnection(await ctx.api.getBusinessConnection(connectionId));
|
||||
logger.info(`Подключение ${connectionId} восстановлено через getBusinessConnection`);
|
||||
} catch (err) {
|
||||
logger.error(`Не удалось получить подключение ${connectionId}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const chatId = ctx.chat?.id;
|
||||
ctx.business = {
|
||||
connectionId,
|
||||
ownerId: record?.ownerId ?? null,
|
||||
userChatId: record?.userChatId ?? null,
|
||||
rights: record?.rights ?? null,
|
||||
isEnabled: record?.isEnabled ?? true,
|
||||
canReply: record?.rights?.can_reply ?? true,
|
||||
isOwner: Boolean(record?.ownerId && ctx.from?.id === record.ownerId),
|
||||
isPaused: chatId != null ? isPaused(connectionId, chatId) : false,
|
||||
pause(ms) {
|
||||
if (chatId == null) return;
|
||||
handovers.set(pauseKey(connectionId, chatId), Date.now() + ms);
|
||||
},
|
||||
resume() {
|
||||
if (chatId == null) return;
|
||||
handovers.delete(pauseKey(connectionId, chatId));
|
||||
},
|
||||
};
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
/** Владелец аккаунта или админ из ADMIN_IDS. */
|
||||
export const isOperator = (ctx) => ctx.business?.isOwner === true || isAdmin(ctx.from?.id);
|
||||
@@ -1,39 +0,0 @@
|
||||
import { config } from '../config.js';
|
||||
import { parseCommand } from '../core/dispatcher.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
/**
|
||||
* Передача диалога человеку.
|
||||
*
|
||||
* В бизнес-чате апдейт business_message приходит и на сообщения клиента,
|
||||
* и на собственные сообщения владельца. Если владелец ответил вручную —
|
||||
* бот замолкает в этом чате на HANDOVER_PAUSE_MINUTES, чтобы клиент
|
||||
* не получал два ответа сразу. Команды владельца при этом продолжают работать.
|
||||
*/
|
||||
export function handover() {
|
||||
return async (ctx, next) => {
|
||||
const business = ctx.business;
|
||||
if (!business) return next();
|
||||
|
||||
const isCommand = Boolean(parseCommand(ctx));
|
||||
|
||||
if (business.isOwner) {
|
||||
if (isCommand) return next();
|
||||
business.pause(config.handoverPauseMs);
|
||||
logger.info(`Владелец отвечает сам в чате ${ctx.chat?.id} — пауза на ${config.handoverPauseMs / 60_000} мин`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (business.isPaused) {
|
||||
logger.debug(`Чат ${ctx.chat?.id} на паузе — молчим`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!business.canReply) {
|
||||
logger.warn(`Нет права can_reply в подключении ${business.connectionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getCachedMessage } from '../core/db.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
/**
|
||||
* Ловит удаления. MTProto в событии удаления присылает ТОЛЬКО id сообщений —
|
||||
* без содержимого и (в личных чатах) без чата. Поэтому восстанавливаем из кэша,
|
||||
* который наполняет модуль [cache]. Нашли в кэше — значит это было входящее
|
||||
* в личке: сохраняем как перехват и уведомляем владельца.
|
||||
*/
|
||||
export default {
|
||||
name: 'antidelete',
|
||||
description: 'Сохраняет удалённые собеседником сообщения и медиа',
|
||||
|
||||
async onDeleted(event, ctx) {
|
||||
const ids = event.deletedIds ?? [];
|
||||
if (!ids.length) return;
|
||||
|
||||
for (const id of ids) {
|
||||
const cached = getCachedMessage(id);
|
||||
if (!cached) continue; // не наше/не кэшировали — пропускаем
|
||||
|
||||
await ctx.capture('deleted', {
|
||||
chatId: cached.chat_id,
|
||||
senderId: cached.sender_id,
|
||||
sender: cached.sender,
|
||||
text: cached.text,
|
||||
mediaKind: cached.media_kind,
|
||||
mediaPath: cached.media_path,
|
||||
});
|
||||
logger.info(`antidelete: восстановлено удалённое сообщение ${id} от ${cached.sender || '?'}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { config } from '../config.js';
|
||||
import { cacheMessage } from '../core/db.js';
|
||||
import { downloadEncrypted, isOneTime } from '../core/media.js';
|
||||
import { isPrivateIncoming } from '../userbot/events.js';
|
||||
|
||||
/**
|
||||
* Фундамент для antidelete: кладёт каждое входящее из личных диалогов в кэш.
|
||||
* При удалении сообщение уже недоступно у Telegram — восстановить его можно
|
||||
* только из этого кэша. Поэтому текст сохраняем всегда, а медиа — сразу качаем
|
||||
* (если включён CACHE_MEDIA), потому что после удаления ссылка на файл мертва.
|
||||
*/
|
||||
export default {
|
||||
name: 'cache',
|
||||
description: 'Кэширует входящие личные сообщения — основа для antidelete',
|
||||
|
||||
async onMessage(message, ctx) {
|
||||
if (!isPrivateIncoming(message, ctx.me)) return;
|
||||
|
||||
// Одноразовые ведёт отдельный модуль onetime — тут их не трогаем,
|
||||
// чтобы не качать дважды.
|
||||
if (isOneTime(message)) return;
|
||||
|
||||
let mediaKind = null;
|
||||
let mediaPath = null;
|
||||
if (message.media && config.cacheMedia) {
|
||||
const saved = await downloadEncrypted(ctx.client, message, 'cache');
|
||||
mediaKind = saved?.kind ?? null;
|
||||
mediaPath = saved?.path ?? null;
|
||||
} else if (message.media) {
|
||||
mediaKind = 'media'; // знаем, что медиа было, но файл не кэшируем
|
||||
}
|
||||
|
||||
const sender = await message.getSender().catch(() => null);
|
||||
cacheMessage({
|
||||
msgId: message.id,
|
||||
chatId: message.chatId,
|
||||
senderId: message.senderId,
|
||||
sender: senderLabel(sender),
|
||||
text: message.text ?? '',
|
||||
mediaKind,
|
||||
mediaPath,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function senderLabel(sender) {
|
||||
if (!sender) return '';
|
||||
const name = [sender.firstName, sender.lastName].filter(Boolean).join(' ');
|
||||
return sender.username ? `${name} (@${sender.username})`.trim() : name;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { downloadEncrypted, isOneTime } from '../core/media.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { isPrivateIncoming } from '../userbot/events.js';
|
||||
|
||||
/**
|
||||
* Одноразовые фото/видео (ttl-медиа) Telegram стирает сразу после просмотра.
|
||||
* Ловим их в момент прихода: качаем файл, шифруем, сохраняем как перехват.
|
||||
* Работает только по входящим в личке — свои и групповые не трогаем.
|
||||
*/
|
||||
export default {
|
||||
name: 'onetime',
|
||||
description: 'Сохраняет одноразовые фото и видео',
|
||||
|
||||
async onMessage(message, ctx) {
|
||||
if (!isPrivateIncoming(message, ctx.me)) return;
|
||||
if (!isOneTime(message)) return;
|
||||
|
||||
const saved = await downloadEncrypted(ctx.client, message, 'onetime');
|
||||
const sender = await message.getSender().catch(() => null);
|
||||
|
||||
await ctx.capture('onetime', {
|
||||
chatId: message.chatId,
|
||||
senderId: message.senderId,
|
||||
sender: senderLabel(sender),
|
||||
text: message.text ?? '',
|
||||
mediaKind: saved?.kind ?? 'media',
|
||||
mediaPath: saved?.path ?? null,
|
||||
});
|
||||
logger.info(`onetime: сохранено одноразовое ${saved?.kind ?? 'медиа'} от ${senderLabel(sender) || '?'}`);
|
||||
},
|
||||
};
|
||||
|
||||
function senderLabel(sender) {
|
||||
if (!sender) return '';
|
||||
const name = [sender.firstName, sender.lastName].filter(Boolean).join(' ');
|
||||
return sender.username ? `${name} (@${sender.username})`.trim() : name;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { TelegramClient } from 'telegram';
|
||||
import { StringSession } from 'telegram/sessions/index.js';
|
||||
|
||||
import { config } from '../config.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
/**
|
||||
* Создаёт (но не подключает) MTProto-клиент из строки сессии в .env.
|
||||
* Логин делается один раз скриптом scripts/login.js — здесь сессия уже готова.
|
||||
*/
|
||||
export function createUserbot(session = config.session) {
|
||||
const client = new TelegramClient(new StringSession(session), config.apiId(), config.apiHash(), {
|
||||
connectionRetries: 5,
|
||||
autoReconnect: true,
|
||||
// Юзербот молчит в чужих чатах — нам нужны только апдейты, не рассылки.
|
||||
floodSleepThreshold: 60,
|
||||
});
|
||||
client.setLogLevel('error');
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function startUserbot(client) {
|
||||
await client.connect();
|
||||
const me = await client.getMe();
|
||||
logger.info(`Юзербот подключён как ${me.username ? '@' + me.username : me.firstName} (id ${me.id})`);
|
||||
return me;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { saveCapture } from '../core/db.js';
|
||||
import { notifyCapture } from '../bot/panel.js';
|
||||
|
||||
/**
|
||||
* Контекст передаётся в каждый хук модуля. Это единственная точка, через которую
|
||||
* модуль что-то делает: сохраняет перехват, уведомляет владельца, качает медиа.
|
||||
* Модуль не знает ни про grammY, ни про схему БД — только про этот интерфейс.
|
||||
*/
|
||||
export function createContext({ client, bot, me, registry }) {
|
||||
return {
|
||||
client, // MTProto-клиент (GramJS)
|
||||
me, // объект себя
|
||||
registry,
|
||||
|
||||
/**
|
||||
* Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted' | 'onetime'.
|
||||
* data: { chatId, senderId, sender, text, mediaKind, mediaPath }
|
||||
*/
|
||||
async capture(kind, data) {
|
||||
const id = saveCapture(kind, data);
|
||||
await notifyCapture(bot, { id, kind, ...data });
|
||||
return id;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NewMessage } from 'telegram/events/NewMessage.js';
|
||||
import { DeletedMessage } from 'telegram/events/DeletedMessage.js';
|
||||
import { EditedMessage } from 'telegram/events/EditedMessage.js';
|
||||
|
||||
import { logger } from '../core/logger.js';
|
||||
import { runHook } from '../core/registry.js';
|
||||
|
||||
/**
|
||||
* Подписывает клиент на апдметы и раздаёт их модулям через хуки.
|
||||
* Мы слушаем всё, но модули сами решают, что делать (обычно — только личку).
|
||||
*/
|
||||
export function wireEvents(client, registry, ctx) {
|
||||
client.addEventHandler(
|
||||
(event) => runHook(registry, 'onMessage', event.message, ctx),
|
||||
new NewMessage({}),
|
||||
);
|
||||
|
||||
client.addEventHandler(
|
||||
(event) => runHook(registry, 'onEdited', event.message, ctx),
|
||||
new EditedMessage({}),
|
||||
);
|
||||
|
||||
client.addEventHandler(
|
||||
(event) => runHook(registry, 'onDeleted', event, ctx),
|
||||
new DeletedMessage({}),
|
||||
);
|
||||
|
||||
logger.debug('события юзербота подписаны: new / edited / deleted');
|
||||
}
|
||||
|
||||
/** Личный диалог один-на-один (не группа, не канал, не свои Избранные). */
|
||||
export function isPrivateIncoming(message, me) {
|
||||
if (!message) return false;
|
||||
if (message.out) return false; // исходящие от себя не перехватываем
|
||||
const cls = message.peerId?.className;
|
||||
return cls === 'PeerUser' && String(message.senderId) !== String(me?.id ?? '');
|
||||
}
|
||||
Reference in New Issue
Block a user