Make bot multi-tenant with colorful welcome and per-owner isolation
The bot is now public: anyone connects it to their own Telegram Business account and gets antidelete for their own private chats. On connection (business_connection enabled) and on /start it sends a colorful welcome describing features, how to connect, and limitations. - connections.js: resolve a connection's owner from business_connection_id (memo -> DB -> getBusinessConnection). Business messages have no outgoing flag, so the owner's own messages are filtered by comparing from.id; if the owner can't be resolved the message is not cached. - Strict per-owner isolation: captures scoped by owner_id; the panel shows each user only their own feed; notifications go to the owner's chat. - db.js: multi-tenant schema (connections table; messages keyed by (conn_id, chat_id, msg_id); captures/counts scoped by owner_id). - media.js: key cached-media filenames by (connId, chatId, msgId) to prevent one tenant overwriting another's encrypted media. - panel.js: drop the owner-only barrier; /start sends welcome + own feed. - config.js: OWNER_ID is now optional (service logs only, grants no access). - Docs: README/.env.example rewritten for the multi-tenant model and the shared-key privacy caveat. - Stop tracking .claude/settings.local.json; restore the Hcrgram/ ignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+29
-33
@@ -1,51 +1,42 @@
|
||||
import { Bot, InlineKeyboard, InputFile } from 'grammy';
|
||||
|
||||
import { config, isOwner } from '../config.js';
|
||||
import { config } 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';
|
||||
import { welcomeText, WELCOME_PARSE_MODE } from './welcome.js';
|
||||
|
||||
const PAGE = 5;
|
||||
const KIND_TITLE = { deleted: '🗑 Удалённые' };
|
||||
|
||||
/** Бизнес-апдейт от подключённого аккаунта: у него ctx.from — собеседник, не владелец. */
|
||||
function isBusinessUpdate(ctx) {
|
||||
return Boolean(
|
||||
ctx.businessMessage ??
|
||||
ctx.editedBusinessMessage ??
|
||||
ctx.deletedBusinessMessages ??
|
||||
ctx.businessConnection,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Компаньон-бот — единственный интерфейс юзербота. Всё управление и все
|
||||
* перехваты живут здесь, в личке владельца. Никаких слеш-команд снаружи:
|
||||
* бот отвечает только владельцу (OWNER_ID), любому другому — молчит.
|
||||
* Бот — интерфейс для всех. Любой может открыть его, нажать Start и получить
|
||||
* приветствие и СВОЮ ленту перехватов. Строгая изоляция: перехваты фильтруются
|
||||
* по id того, кто обратился (ctx.from.id) — чужого не видно.
|
||||
*
|
||||
* Бизнес-апд엘ты (business_message/deleted/connection) сюда не попадают —
|
||||
* их обрабатывает wireBusiness через bot.on(...). Панель живёт на обычных
|
||||
* message/callback_query, поэтому барьер доступа не нужен.
|
||||
*/
|
||||
export function createBot() {
|
||||
const bot = new Bot(config.botToken());
|
||||
|
||||
// Жёсткий барьер: панельные апдейты обрабатываем только от владельца.
|
||||
// Бизнес-апдейты (business_message/deleted/…) пропускаем — у них ctx.from
|
||||
// это собеседник, а не владелец; их разбирает wireBusiness отдельно.
|
||||
bot.use(async (ctx, next) => {
|
||||
if (isBusinessUpdate(ctx) || isOwner(ctx.from?.id)) return next();
|
||||
logger.debug(`bot: игнорирую чужой апдейт от ${ctx.from?.id}`);
|
||||
bot.command('start', async (ctx) => {
|
||||
const text = welcomeText({ name: ctx.from?.first_name });
|
||||
await ctx.reply(text, { parse_mode: WELCOME_PARSE_MODE, reply_markup: mainMenu(ctx.from.id) });
|
||||
});
|
||||
|
||||
bot.command('start', (ctx) => ctx.reply('Панель юзербота.', { reply_markup: mainMenu() }));
|
||||
bot.callbackQuery('home', (ctx) => edit(ctx, 'Панель юзербота.', mainMenu()));
|
||||
bot.callbackQuery('home', (ctx) => edit(ctx, 'Панель — ваши перехваты.', mainMenu(ctx.from.id)));
|
||||
|
||||
// Списки перехватов с пагинацией.
|
||||
// Списки перехватов с пагинацией (только свои).
|
||||
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();
|
||||
@@ -55,9 +46,8 @@ export function createBot() {
|
||||
return bot;
|
||||
}
|
||||
|
||||
function mainMenu() {
|
||||
return new InlineKeyboard()
|
||||
.text(`🗑 Удалённые (${countCaptures('deleted')})`, 'feed:deleted:0');
|
||||
function mainMenu(ownerId) {
|
||||
return new InlineKeyboard().text(`🗑 Удалённые (${countCaptures(ownerId, 'deleted')})`, 'feed:deleted:0');
|
||||
}
|
||||
|
||||
function feedKeyboard(kind, page, items, total) {
|
||||
@@ -74,8 +64,9 @@ function feedKeyboard(kind, page, items, total) {
|
||||
}
|
||||
|
||||
async function renderFeed(ctx, kind, page) {
|
||||
const total = countCaptures(kind);
|
||||
const items = listCaptures({ kind, limit: PAGE, offset: page * PAGE });
|
||||
const ownerId = ctx.from.id;
|
||||
const total = countCaptures(ownerId, kind);
|
||||
const items = listCaptures({ ownerId, 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))}`
|
||||
@@ -84,7 +75,7 @@ async function renderFeed(ctx, kind, page) {
|
||||
}
|
||||
|
||||
async function renderItem(ctx, id) {
|
||||
const item = getCapture(id);
|
||||
const item = getCapture(id, ctx.from.id);
|
||||
if (!item) return edit(ctx, 'Запись не найдена (возможно, удалена по сроку хранения).', backTo('home'));
|
||||
|
||||
const caption = formatCapture(item);
|
||||
@@ -145,15 +136,20 @@ async function edit(ctx, text, keyboard) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Присылает владельцу уведомление о свежем перехвате. Дёргается из модулей
|
||||
* через контекст (ctx.notify) — модуль не знает деталей бота.
|
||||
* Уведомляет ВЛАДЕЛЬЦА подключения о свежем перехвате — в его личку с ботом.
|
||||
* item.ownerChatId — куда слать (owner_chat_id из бизнес-подключения).
|
||||
* Дёргается из модулей через контекст (ctx.capture → notifyCapture).
|
||||
*/
|
||||
export async function notifyCapture(bot, item) {
|
||||
if (!item.ownerChatId) {
|
||||
logger.warn(`bot: некому отправить уведомление о перехвате ${item.id} (нет ownerChatId)`);
|
||||
return;
|
||||
}
|
||||
const kb = new InlineKeyboard().text('Открыть', `item:${item.id}`);
|
||||
const head = '🗑 Удалённое сообщение';
|
||||
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 });
|
||||
await bot.api.sendMessage(item.ownerChatId, text, { reply_markup: kb });
|
||||
} catch (err) {
|
||||
logger.warn('bot: не удалось уведомить владельца', err.message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Красочное приветствие. Показывается в двух местах: на /start в личке бота
|
||||
* и когда человек подключает бота к своему бизнес-аккаунту (business_connection).
|
||||
* Формат — Markdown (parse_mode: 'Markdown').
|
||||
*
|
||||
* Разделы (по выбору владельца проекта): что умеет, как подключить, ограничения.
|
||||
* Ни от кого не зависит и ничего не хранит — просто текст.
|
||||
*/
|
||||
export const WELCOME_PARSE_MODE = 'Markdown';
|
||||
|
||||
const FEATURES = [
|
||||
'🗑 *Antidelete* — сохраняю сообщения и медиа, которые собеседник удалил у вас в личке.',
|
||||
'🔔 *Уведомления* — как только что-то удалено, пришлю карточку с кнопкой «Открыть».',
|
||||
'🗂 *Личная лента* — все ваши перехваты в одном месте, с постраничным просмотром.',
|
||||
'🔐 *Локально и зашифровано* — данные лежат только на сервере бота (AES-256), у каждого — свои.',
|
||||
];
|
||||
|
||||
const HOW_TO = [
|
||||
'1️⃣ В *@BotFather* у этого бота включите *Business Mode*.',
|
||||
'2️⃣ В Telegram: *Настройки → Telegram для бизнеса → Чат-боты*.',
|
||||
'3️⃣ Добавьте меня и разрешите управлять сообщениями.',
|
||||
'4️⃣ Готово — я начну беречь ваши личные переписки. Возвращайтесь сюда за лентой.',
|
||||
];
|
||||
|
||||
const LIMITS = [
|
||||
'• Одноразовые медиа («просмотр один раз») перехватить нельзя — Telegram не отдаёт их ботам.',
|
||||
'• Файлы больше ~20 МБ не сохраняются целиком (ограничение Bot API).',
|
||||
'• Работаю только в личных 1-на-1 чатах, не в группах и каналах.',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.name] — имя, чтобы поздороваться лично.
|
||||
* @param {boolean} [opts.connected] — true, если это отклик на подключение бизнеса.
|
||||
*/
|
||||
export function welcomeText({ name, connected = false } = {}) {
|
||||
const hi = name ? `Привет, *${escapeMd(name)}*! ` : 'Привет! ';
|
||||
const head = connected
|
||||
? `${hi}✅ Бот подключён к вашему бизнес-аккаунту — antidelete включён.`
|
||||
: `${hi}Я бот-хранитель личных переписок для Telegram Business.`;
|
||||
|
||||
return [
|
||||
head,
|
||||
'',
|
||||
'*Что я умею*',
|
||||
...FEATURES,
|
||||
'',
|
||||
'*Как подключить*',
|
||||
...HOW_TO,
|
||||
'',
|
||||
'*Важно знать*',
|
||||
...LIMITS,
|
||||
'',
|
||||
connected
|
||||
? '📂 Открыть свои перехваты можно кнопкой ниже или командой /start.'
|
||||
: '📂 Ниже — ваша лента перехватов.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Экранируем символы, ломающие Markdown-разметку в подставляемом имени. */
|
||||
function escapeMd(s) {
|
||||
return String(s).replace(/([_*`\[\]])/g, '\\$1');
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getConnection, upsertConnection } from '../core/db.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
/**
|
||||
* Резолвер владельца по business_connection_id.
|
||||
*
|
||||
* Зачем: у бизнес-сообщений НЕТ флага «исходящее». Единственный надёжный способ
|
||||
* отличить сообщение владельца от сообщения собеседника — сравнить from.id с id
|
||||
* владельца подключения. А уведомления об удалении надо слать в личку владельца
|
||||
* (owner_chat_id). И то, и другое берём из подключения.
|
||||
*
|
||||
* Порядок поиска: память → БД → getBusinessConnection (и результат кэшируем).
|
||||
* getBusinessConnection нужен после рестарта, если событие connection пришло до
|
||||
* запуска и в памяти пусто, но в БД запись есть — тогда чаще хватит БД.
|
||||
*/
|
||||
const memo = new Map(); // connId -> { ownerId, ownerChatId, ownerName, ownerUsername, isEnabled }
|
||||
|
||||
/** Кладёт/обновляет подключение и в память, и в БД. Возвращает нормализованную запись. */
|
||||
export function rememberConnection(conn) {
|
||||
const record = {
|
||||
connId: String(conn.id),
|
||||
ownerId: conn.user?.id != null ? String(conn.user.id) : '',
|
||||
ownerChatId: conn.user_chat_id != null ? String(conn.user_chat_id) : '',
|
||||
ownerName: [conn.user?.first_name, conn.user?.last_name].filter(Boolean).join(' '),
|
||||
ownerUsername: conn.user?.username ?? '',
|
||||
isEnabled: Boolean(conn.is_enabled),
|
||||
};
|
||||
memo.set(record.connId, record);
|
||||
upsertConnection(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает { ownerId, ownerChatId, ... } по connId или null.
|
||||
* api — bot.api (для getBusinessConnection); можно не передавать, тогда только кэш/БД.
|
||||
*/
|
||||
export async function resolveOwner(connId, api) {
|
||||
if (connId == null) return null;
|
||||
const key = String(connId);
|
||||
|
||||
const cached = memo.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const row = getConnection(key);
|
||||
if (row) {
|
||||
const record = {
|
||||
connId: key,
|
||||
ownerId: row.owner_id ?? '',
|
||||
ownerChatId: row.owner_chat_id ?? '',
|
||||
ownerName: row.owner_name ?? '',
|
||||
ownerUsername: row.owner_username ?? '',
|
||||
isEnabled: Boolean(row.is_enabled),
|
||||
};
|
||||
memo.set(key, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
if (api?.getBusinessConnection) {
|
||||
try {
|
||||
const conn = await api.getBusinessConnection(key);
|
||||
if (conn?.id) return rememberConnection(conn);
|
||||
} catch (err) {
|
||||
logger.warn(`connections: getBusinessConnection(${key}) не удался:`, err.message);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -13,7 +13,8 @@ export function createContext({ bot, registry }) {
|
||||
|
||||
/**
|
||||
* Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted'.
|
||||
* data: { chatId, senderId, sender, text, mediaKind, mediaPath }
|
||||
* data: { ownerId, ownerChatId, chatId, senderId, sender, text, mediaKind, mediaPath }
|
||||
* ownerId — чей это перехват (изоляция ленты), ownerChatId — куда уведомлять.
|
||||
*/
|
||||
async capture(kind, data) {
|
||||
const id = saveCapture(kind, data);
|
||||
|
||||
+60
-20
@@ -1,65 +1,105 @@
|
||||
import { isOwner } from '../config.js';
|
||||
import { welcomeText, WELCOME_PARSE_MODE } from '../bot/welcome.js';
|
||||
import { detectMediaFromBusiness } from '../core/media.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { runHook } from '../core/registry.js';
|
||||
import { rememberConnection, resolveOwner } from './connections.js';
|
||||
|
||||
/**
|
||||
* Подписывает бота на бизнес-апдейты Telegram и раздаёт их модулям через хуки.
|
||||
* Работает через официальное бизнес-подключение (Настройки → Telegram для
|
||||
* бизнеса → Чат-боты) — никакой сессии, только апдейты от подключённого аккаунта.
|
||||
* бизнеса → Чат-боты) — никакой сессии, только апдейты от подключённых аккаунтов.
|
||||
*
|
||||
* Мультитенант: бота подключают разные владельцы. У бизнес-сообщений НЕТ флага
|
||||
* «исходящее», поэтому по business_connection_id восстанавливаем владельца и
|
||||
* сравниваем with from.id — так отсекаем собственные сообщения владельца. Владелец
|
||||
* нужен и для адресации: уведомления/приветствие шлём в его личку (owner_chat_id).
|
||||
*
|
||||
* Событие удаления (deleted_business_messages) несёт только id сообщений и чат —
|
||||
* без содержимого. Поэтому модуль cache складывает каждое входящее заранее,
|
||||
* а antidelete достаёт его из кэша по (chatId, msgId).
|
||||
* а antidelete достаёт его из кэша по (connId, chatId, msgId).
|
||||
*/
|
||||
export function wireBusiness(bot, registry, ctx) {
|
||||
bot.on('business_message', (gctx) =>
|
||||
runHook(registry, 'onMessage', normalizeBusinessMessage(gctx), ctx),
|
||||
);
|
||||
bot.on('business_message', async (gctx) => {
|
||||
const msg = await normalizeBusinessMessage(gctx);
|
||||
return runHook(registry, 'onMessage', msg, ctx);
|
||||
});
|
||||
|
||||
bot.on('edited_business_message', (gctx) =>
|
||||
runHook(registry, 'onEdited', normalizeBusinessMessage(gctx), ctx),
|
||||
);
|
||||
bot.on('edited_business_message', async (gctx) => {
|
||||
const msg = await normalizeBusinessMessage(gctx);
|
||||
return runHook(registry, 'onEdited', msg, ctx);
|
||||
});
|
||||
|
||||
bot.on('deleted_business_messages', (gctx) => {
|
||||
bot.on('deleted_business_messages', async (gctx) => {
|
||||
const d = gctx.deletedBusinessMessages;
|
||||
const connId = d.business_connection_id != null ? String(d.business_connection_id) : '';
|
||||
const owner = await resolveOwner(connId, gctx.api);
|
||||
return runHook(
|
||||
registry,
|
||||
'onDeleted',
|
||||
{ chatId: d.chat?.id, msgIds: d.message_ids ?? [] },
|
||||
{
|
||||
connId,
|
||||
ownerId: owner?.ownerId ?? '',
|
||||
ownerChatId: owner?.ownerChatId ?? '',
|
||||
chatId: d.chat?.id,
|
||||
msgIds: d.message_ids ?? [],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
bot.on('business_connection', (gctx) => {
|
||||
// Подключение/отключение бота владельцем. Здесь же — красочное приветствие.
|
||||
bot.on('business_connection', async (gctx) => {
|
||||
const c = gctx.businessConnection;
|
||||
if (c?.is_enabled) {
|
||||
logger.info(`Бизнес-подключение активно (id ${c.id})`);
|
||||
if (!c) return;
|
||||
const record = rememberConnection(c);
|
||||
if (c.is_enabled) {
|
||||
logger.info(`Бизнес-подключение активно (владелец ${record.ownerId || '?'}, id ${c.id})`);
|
||||
await greetOwner(gctx.api, record);
|
||||
} else {
|
||||
logger.warn(`Бизнес-подключение отключено владельцем (id ${c?.id}). Перехват приостановлен.`);
|
||||
logger.warn(`Бизнес-подключение отключено (id ${c.id}). Перехват для владельца приостановлен.`);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('бизнес-апдейты подписаны: message / edited / deleted / connection');
|
||||
}
|
||||
|
||||
/** Шлёт приветствие владельцу в его личку с ботом сразу после подключения. */
|
||||
async function greetOwner(api, record) {
|
||||
if (!record.ownerChatId) return;
|
||||
try {
|
||||
await api.sendMessage(record.ownerChatId, welcomeText({ name: record.ownerName, connected: true }), {
|
||||
parse_mode: WELCOME_PARSE_MODE,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('business: не удалось отправить приветствие владельцу', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Приводит grammY-контекст бизнес-сообщения к плоскому виду, понятному модулям.
|
||||
* isPrivateIncoming — входящее в личном 1-на-1 чате, не от самого владельца.
|
||||
* Приводит grammY-контекст бизнес-сообщения к плоскому виду, понятному модулям,
|
||||
* и подмешивает владельца подключения (connId/ownerId/ownerChatId).
|
||||
* isPrivateIncoming — входящее в личном 1-на-1 чате, НЕ от самого владельца.
|
||||
* Если владельца распознать не удалось (ownerId пуст) — считаем сообщение
|
||||
* неопределённым и НЕ кэшируем: лучше пропустить, чем сохранить своё исходящее.
|
||||
*/
|
||||
export function normalizeBusinessMessage(gctx) {
|
||||
export async function normalizeBusinessMessage(gctx) {
|
||||
const msg = gctx.businessMessage ?? gctx.editedBusinessMessage ?? gctx.msg;
|
||||
const from = msg?.from;
|
||||
const chat = msg?.chat;
|
||||
const connId = msg?.business_connection_id != null ? String(msg.business_connection_id) : '';
|
||||
const owner = await resolveOwner(connId, gctx.api);
|
||||
const ownerId = owner?.ownerId ?? '';
|
||||
return {
|
||||
connId,
|
||||
ownerId,
|
||||
ownerChatId: owner?.ownerChatId ?? '',
|
||||
chatId: chat?.id,
|
||||
msgId: msg?.message_id,
|
||||
senderId: from?.id,
|
||||
sender: senderLabel(from),
|
||||
text: msg?.text ?? msg?.caption ?? '',
|
||||
media: detectMediaFromBusiness(msg),
|
||||
// Свои исходящие отсекаем через isOwner (как раньше через message.out).
|
||||
isPrivateIncoming: chat?.type === 'private' && !isOwner(from?.id),
|
||||
isPrivateIncoming: chat?.type === 'private' && ownerId !== '' && String(from?.id) !== ownerId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+9
-7
@@ -16,13 +16,18 @@ const num = (name) => {
|
||||
* Конфиг бота. Работает через официальное бизнес-подключение Telegram —
|
||||
* никакой строки сессии, только токен бота от @BotFather. Всё чувствительное
|
||||
* (токен, ключ шифрования) живёт только в .env и никуда не уходит.
|
||||
*
|
||||
* Мультитенант: любой может подключить бота к своему бизнес-аккаунту. Доступ
|
||||
* к перехватам изолирован по владельцу (см. db.js), поэтому OWNER_ID больше
|
||||
* ничего не открывает и не обязателен.
|
||||
*/
|
||||
export const config = {
|
||||
// Бот-панель (@BotFather). К нему же владелец подключает бизнес-аккаунт
|
||||
// (Настройки → Telegram для бизнеса → Чат-боты). Единственный UI.
|
||||
// Бот (@BotFather, Business Mode). К нему пользователи подключают свои
|
||||
// бизнес-аккаунты (Настройки → Telegram для бизнеса → Чат-боты).
|
||||
botToken: () => req('BOT_TOKEN'),
|
||||
// Telegram id владельца: только он управляет ботом и получает перехваты.
|
||||
ownerId: () => num('OWNER_ID'),
|
||||
// Необязательный id «оператора» бота — только для служебных логов, доступ
|
||||
// к чужим данным он НЕ даёт (строгая изоляция). null, если не задан.
|
||||
ownerId: () => (process.env.OWNER_ID ? num('OWNER_ID') : null),
|
||||
|
||||
// Ключ шифрования локального хранилища (32+ байта, hex или ascii).
|
||||
encryptionKey: () => req('ENCRYPTION_KEY'),
|
||||
@@ -45,11 +50,8 @@ export const mediaPath = () => fileURLToPath(config.mediaDir);
|
||||
/** Проверяем всё, что нужно для старта, одним махом — понятная ошибка вместо падения в рантайме. */
|
||||
export function assertConfig() {
|
||||
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-символа).');
|
||||
}
|
||||
}
|
||||
|
||||
export const isOwner = (id) => Number(id) === config.ownerId();
|
||||
|
||||
+98
-29
@@ -10,13 +10,27 @@ let db = null;
|
||||
/**
|
||||
* Локальный индекс на node:sqlite. Тексты храним зашифрованными (AES-GCM),
|
||||
* бинарные медиа лежат отдельными файлами в data/media, здесь только путь.
|
||||
*
|
||||
* Мультитенант: бот подключают к своим бизнес-аккаунтам разные люди. Каждое
|
||||
* подключение — строка в connections (conn_id → владелец). Кэш входящих и
|
||||
* перехваты привязаны к владельцу, поэтому в панели каждый видит только своё.
|
||||
*/
|
||||
export function openDb() {
|
||||
if (db) return db;
|
||||
mkdirSync(dataPath(), { recursive: true });
|
||||
db = new DatabaseSync(`${dataPath()}/business.db`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
conn_id TEXT PRIMARY KEY,
|
||||
owner_id TEXT,
|
||||
owner_chat_id TEXT,
|
||||
owner_name TEXT,
|
||||
owner_username TEXT,
|
||||
is_enabled INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
conn_id TEXT,
|
||||
msg_id INTEGER,
|
||||
chat_id TEXT,
|
||||
sender_id TEXT,
|
||||
@@ -25,20 +39,21 @@ export function openDb() {
|
||||
media_kind TEXT,
|
||||
media_path TEXT,
|
||||
created_at INTEGER,
|
||||
PRIMARY KEY (chat_id, msg_id)
|
||||
PRIMARY KEY (conn_id, chat_id, msg_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS captures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT, -- 'deleted'
|
||||
chat_id TEXT,
|
||||
sender_id TEXT,
|
||||
sender TEXT,
|
||||
text_enc TEXT,
|
||||
media_kind TEXT,
|
||||
media_path TEXT,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id TEXT,
|
||||
kind TEXT, -- 'deleted'
|
||||
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);
|
||||
CREATE INDEX IF NOT EXISTS idx_captures_owner_time ON captures(owner_id, captured_at DESC);
|
||||
`);
|
||||
logger.debug('sqlite: база готова');
|
||||
return db;
|
||||
@@ -46,15 +61,53 @@ export function openDb() {
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
/** Кладём каждое входящее в кольцевой кэш — источник для восстановления удалённых. */
|
||||
export function cacheMessage({ msgId, chatId, senderId, sender, text, mediaKind, mediaPath }) {
|
||||
/**
|
||||
* Сохраняет/обновляет бизнес-подключение. Ключ — conn_id (business_connection_id),
|
||||
* по нему потом восстанавливаем владельца (для адресации уведомлений и отсева
|
||||
* исходящих владельца). Зовётся из события business_connection.
|
||||
*/
|
||||
export function upsertConnection({ connId, ownerId, ownerChatId, ownerName, ownerUsername, isEnabled }) {
|
||||
openDb()
|
||||
.prepare(
|
||||
`INSERT INTO connections
|
||||
(conn_id, owner_id, owner_chat_id, owner_name, owner_username, is_enabled, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(conn_id) DO UPDATE SET
|
||||
owner_id=excluded.owner_id,
|
||||
owner_chat_id=excluded.owner_chat_id,
|
||||
owner_name=excluded.owner_name,
|
||||
owner_username=excluded.owner_username,
|
||||
is_enabled=excluded.is_enabled,
|
||||
updated_at=excluded.updated_at`,
|
||||
)
|
||||
.run(
|
||||
String(connId),
|
||||
String(ownerId ?? ''),
|
||||
String(ownerChatId ?? ''),
|
||||
ownerName ?? '',
|
||||
ownerUsername ?? '',
|
||||
isEnabled ? 1 : 0,
|
||||
now(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Возвращает подключение по conn_id (или null). owner_id/owner_chat_id — строки. */
|
||||
export function getConnection(connId) {
|
||||
return (
|
||||
openDb().prepare('SELECT * FROM connections WHERE conn_id = ?').get(String(connId ?? '')) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Кладём каждое входящее в кэш — источник для восстановления удалённых. */
|
||||
export function cacheMessage({ connId, 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 (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(conn_id, msg_id, chat_id, sender_id, sender, text_enc, media_kind, media_path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
String(connId ?? ''),
|
||||
msgId,
|
||||
String(chatId ?? ''),
|
||||
String(senderId ?? ''),
|
||||
@@ -66,10 +119,10 @@ export function cacheMessage({ msgId, chatId, senderId, sender, text, mediaKind,
|
||||
);
|
||||
}
|
||||
|
||||
export function getCachedMessage(chatId, msgId) {
|
||||
export function getCachedMessage(connId, chatId, msgId) {
|
||||
const row = openDb()
|
||||
.prepare('SELECT * FROM messages WHERE chat_id = ? AND msg_id = ?')
|
||||
.get(String(chatId ?? ''), msgId);
|
||||
.prepare('SELECT * FROM messages WHERE conn_id = ? AND chat_id = ? AND msg_id = ?')
|
||||
.get(String(connId ?? ''), String(chatId ?? ''), msgId);
|
||||
return row ? decodeRow(row) : null;
|
||||
}
|
||||
|
||||
@@ -78,10 +131,11 @@ 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 (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(owner_id, kind, chat_id, sender_id, sender, text_enc, media_kind, media_path, captured_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
String(data.ownerId ?? ''),
|
||||
kind,
|
||||
String(data.chatId ?? ''),
|
||||
String(data.senderId ?? ''),
|
||||
@@ -94,24 +148,39 @@ export function saveCapture(kind, data) {
|
||||
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];
|
||||
/** Лента перехватов конкретного владельца (строгая изоляция между пользователями). */
|
||||
export function listCaptures({ ownerId, kind, limit = 10, offset = 0 } = {}) {
|
||||
const where = ['owner_id = ?'];
|
||||
const args = [String(ownerId ?? '')];
|
||||
if (kind) {
|
||||
where.push('kind = ?');
|
||||
args.push(kind);
|
||||
}
|
||||
args.push(limit, offset);
|
||||
return openDb()
|
||||
.prepare(`SELECT * FROM captures ${where} ORDER BY captured_at DESC LIMIT ? OFFSET ?`)
|
||||
.prepare(
|
||||
`SELECT * FROM captures WHERE ${where.join(' AND ')} 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);
|
||||
/** Одна запись — только если принадлежит запросившему владельцу. */
|
||||
export function getCapture(id, ownerId) {
|
||||
const row = openDb()
|
||||
.prepare('SELECT * FROM captures WHERE id = ? AND owner_id = ?')
|
||||
.get(id, String(ownerId ?? ''));
|
||||
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;
|
||||
export function countCaptures(ownerId, kind) {
|
||||
const where = ['owner_id = ?'];
|
||||
const args = [String(ownerId ?? '')];
|
||||
if (kind) {
|
||||
where.push('kind = ?');
|
||||
args.push(kind);
|
||||
}
|
||||
return openDb().prepare(`SELECT COUNT(*) AS n FROM captures WHERE ${where.join(' AND ')}`).get(...args).n;
|
||||
}
|
||||
|
||||
/** Чистка по retentionDays — вызывается по таймеру из index.js. */
|
||||
|
||||
+12
-2
@@ -29,8 +29,13 @@ export function detectMediaFromBusiness(msg) {
|
||||
* Возвращает имя файла (как хранится в БД) либо null.
|
||||
* getFile отдаёт файлы до ~20 МБ — на бо́льших вернётся ошибка, её ловим.
|
||||
* Качать нужно в момент прихода: при удалении file_id уже не приходит.
|
||||
*
|
||||
* key = { connId, chatId, msgId, tag } — уникализирует имя файла между
|
||||
* подключениями и чатами (мультитенант): msg_id уникален только внутри чата,
|
||||
* поэтому без connId/chatId два владельца могли бы затереть медиа друг друга.
|
||||
*/
|
||||
export async function downloadEncrypted(api, fileId, tag = 'm', msgId = 'x') {
|
||||
export async function downloadEncrypted(api, fileId, key = {}) {
|
||||
const { connId = '', chatId = '', msgId = 'x', tag = 'm' } = key;
|
||||
try {
|
||||
const file = await api.getFile(fileId); // { file_path, ... } или бросит на >20 МБ
|
||||
if (!file?.file_path) return null;
|
||||
@@ -42,7 +47,7 @@ export async function downloadEncrypted(api, fileId, tag = 'm', msgId = 'x') {
|
||||
if (!buffer.length) return null;
|
||||
|
||||
mkdirSync(mediaPath(), { recursive: true });
|
||||
const name = `${tag}_${msgId}_${buffer.length}.enc`;
|
||||
const name = `${tag}_${seg(connId)}_${seg(chatId)}_${seg(msgId)}_${buffer.length}.enc`;
|
||||
const full = fileURLToPath(new URL(name, config.mediaDir));
|
||||
await writeFile(full, encrypt(buffer));
|
||||
logger.debug(`media: сохранено -> ${name} (${buffer.length} б)`);
|
||||
@@ -53,6 +58,11 @@ export async function downloadEncrypted(api, fileId, tag = 'm', msgId = 'x') {
|
||||
}
|
||||
}
|
||||
|
||||
/** Делает сегмент имени файла безопасным: минус → 'm', прочее не-словесное убираем. */
|
||||
function seg(v) {
|
||||
return String(v).replace(/-/g, 'm').replace(/[^A-Za-z0-9]+/g, '') || 'x';
|
||||
}
|
||||
|
||||
/** Читает зашифрованный медиа-файл с диска по имени (как хранится в БД). */
|
||||
export function readMedia(name) {
|
||||
return readFile(fileURLToPath(new URL(name, config.mediaDir)));
|
||||
|
||||
@@ -4,21 +4,23 @@ import { logger } from '../core/logger.js';
|
||||
/**
|
||||
* Ловит удаления. Telegram в событии deleted_business_messages присылает ТОЛЬКО
|
||||
* id сообщений и чат — без содержимого. Поэтому восстанавливаем из кэша, который
|
||||
* наполняет модуль [cache], по паре (chatId, msgId). Нашли — значит это было
|
||||
* входящее в личке: сохраняем как перехват и уведомляем владельца.
|
||||
* наполняет модуль [cache], по тройке (connId, chatId, msgId). Нашли — значит это
|
||||
* было входящее в личке: сохраняем как перехват владельца и уведомляем его.
|
||||
*/
|
||||
export default {
|
||||
name: 'antidelete',
|
||||
description: 'Сохраняет удалённые собеседником сообщения и медиа',
|
||||
|
||||
async onDeleted({ chatId, msgIds }, ctx) {
|
||||
async onDeleted({ connId, ownerId, ownerChatId, chatId, msgIds }, ctx) {
|
||||
if (!msgIds?.length) return;
|
||||
|
||||
for (const id of msgIds) {
|
||||
const cached = getCachedMessage(chatId, id);
|
||||
const cached = getCachedMessage(connId, chatId, id);
|
||||
if (!cached) continue; // не наше/не кэшировали — пропускаем
|
||||
|
||||
await ctx.capture('deleted', {
|
||||
ownerId,
|
||||
ownerChatId,
|
||||
chatId: cached.chat_id,
|
||||
senderId: cached.sender_id,
|
||||
sender: cached.sender,
|
||||
|
||||
@@ -19,12 +19,18 @@ export default {
|
||||
let mediaPath = null;
|
||||
if (msg.media && config.cacheMedia) {
|
||||
mediaKind = msg.media.kind;
|
||||
mediaPath = await downloadEncrypted(ctx.api, msg.media.fileId, 'cache', msg.msgId);
|
||||
mediaPath = await downloadEncrypted(ctx.api, msg.media.fileId, {
|
||||
connId: msg.connId,
|
||||
chatId: msg.chatId,
|
||||
msgId: msg.msgId,
|
||||
tag: 'cache',
|
||||
});
|
||||
} else if (msg.media) {
|
||||
mediaKind = msg.media.kind; // знаем, что медиа было, но файл не кэшируем
|
||||
}
|
||||
|
||||
cacheMessage({
|
||||
connId: msg.connId,
|
||||
msgId: msg.msgId,
|
||||
chatId: msg.chatId,
|
||||
senderId: msg.senderId,
|
||||
|
||||
Reference in New Issue
Block a user