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:
2026-08-09 22:16:44 +03:00
parent 8bd13b4dd5
commit 0e19c84a6d
14 changed files with 385 additions and 169 deletions
+29 -33
View File
@@ -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);
}
+63
View File
@@ -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');
}
+67
View File
@@ -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;
}
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)));
+6 -4
View File
@@ -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,
+7 -1
View File
@@ -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,