Files
arestools/src/business/events.js
T
ros 0e19c84a6d 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>
2026-08-09 22:16:44 +03:00

111 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 достаёт его из кэша по (connId, chatId, msgId).
*/
export function wireBusiness(bot, registry, ctx) {
bot.on('business_message', async (gctx) => {
const msg = await normalizeBusinessMessage(gctx);
return runHook(registry, 'onMessage', msg, ctx);
});
bot.on('edited_business_message', async (gctx) => {
const msg = await normalizeBusinessMessage(gctx);
return runHook(registry, 'onEdited', msg, ctx);
});
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',
{
connId,
ownerId: owner?.ownerId ?? '',
ownerChatId: owner?.ownerChatId ?? '',
chatId: d.chat?.id,
msgIds: d.message_ids ?? [],
},
ctx,
);
});
// Подключение/отключение бота владельцем. Здесь же — красочное приветствие.
bot.on('business_connection', async (gctx) => {
const c = gctx.businessConnection;
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.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-контекст бизнес-сообщения к плоскому виду, понятному модулям,
* и подмешивает владельца подключения (connId/ownerId/ownerChatId).
* isPrivateIncoming — входящее в личном 1-на-1 чате, НЕ от самого владельца.
* Если владельца распознать не удалось (ownerId пуст) — считаем сообщение
* неопределённым и НЕ кэшируем: лучше пропустить, чем сохранить своё исходящее.
*/
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),
isPrivateIncoming: chat?.type === 'private' && ownerId !== '' && String(from?.id) !== ownerId,
};
}
function senderLabel(from) {
if (!from) return '';
const name = [from.first_name, from.last_name].filter(Boolean).join(' ');
return from.username ? `${name} (@${from.username})`.trim() : name;
}