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