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
+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,