Files
IdP/apps/sso-core/src/domain/bot/bot-api.service.ts
2026-06-26 17:49:22 +03:00

1186 lines
39 KiB
TypeScript
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 { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../infra/prisma.service';
import { NotificationsService } from '../notifications.service';
import { BotCallbackRegistryService } from './bot-callback-registry.service';
import { formatTokenForLog } from './bot-debug-log.util';
import { BotFatherService } from './bot-father.service';
import { BotRateLimitService } from './bot-rate-limit.service';
import { BotUpdateQueueService } from './bot-update-queue.service';
import { deriveTelegramChatId, hashBotToken } from './bot-token.util';
import { serializeTelegramResponse, telegramError, telegramOk } from './telegram-response.util';
import { TelegramSerializerService } from './telegram-serializer.service';
import { WebAppCryptoService } from './webapp-crypto.service';
import { BOTFATHER_BOT_USERNAME, buildBotManageWebAppUrl } from './bot-father.constants';
import {
menuButtonToJson,
parseMenuButtonInput,
resolveComposerMenuButton,
} from './bot-menu-button.util';
import { assertHumanAccount } from '../system-account.util';
type ValidatedBot = {
id: string;
name: string;
username: string;
ownerId: string;
webAppUrl?: string | null;
menuButton?: unknown;
webhookUrl?: string | null;
webhookSecretToken?: string | null;
isActive: boolean;
isSystemBot: boolean;
};
type ChatPayload = {
chat_id?: string | number;
message_id?: number | string;
};
type SendMessagePayload = ChatPayload & {
text?: string;
parse_mode?: string;
reply_markup?: unknown;
disable_web_page_preview?: boolean;
};
type EditMessageTextPayload = ChatPayload & {
text?: string;
parse_mode?: string;
reply_markup?: unknown;
};
type EditMessageReplyMarkupPayload = ChatPayload & {
reply_markup?: unknown;
};
type PinChatMessagePayload = ChatPayload & {
disable_notification?: boolean;
};
type AnswerCallbackQueryPayload = {
callback_query_id?: string;
text?: string;
show_alert?: boolean;
url?: string;
};
type MediaPayload = ChatPayload & {
photo?: string;
document?: string;
caption?: string;
reply_markup?: unknown;
};
type SetWebhookPayload = {
url?: string;
secret_token?: string;
drop_pending_updates?: boolean;
};
type GetUpdatesPayload = {
offset?: number | string;
limit?: number | string;
timeout?: number | string;
};
type SetChatMenuButtonPayload = {
chat_id?: string | number | null;
menu_button?: unknown;
};
@Injectable()
export class BotApiService {
private readonly logger = new Logger(BotApiService.name);
constructor(
private readonly prisma: PrismaService,
private readonly botFather: BotFatherService,
private readonly rateLimit: BotRateLimitService,
private readonly notifications: NotificationsService,
private readonly webAppCrypto: WebAppCryptoService,
private readonly updateQueue: BotUpdateQueueService,
private readonly serializer: TelegramSerializerService,
private readonly callbackRegistry: BotCallbackRegistryService
) {}
async executeMethod(token: string, method: string, payload: Record<string, unknown>) {
const bot = (await this.botFather.validateBotToken(token)) as ValidatedBot | null;
if (!bot) {
this.logger.warn(`Unauthorized Bot API call\n${formatTokenForLog(token)}`);
return {
httpStatus: 401,
responseJson: serializeTelegramResponse(telegramError(401, 'Unauthorized'))
};
}
const rateCheck = await this.rateLimit.assertBotApiRateLimit(hashBotToken(token));
if (!rateCheck.allowed) {
return {
httpStatus: 429,
responseJson: serializeTelegramResponse(
telegramError(429, 'Too Many Requests: retry later', { retry_after: rateCheck.retryAfter })
)
};
}
const normalizedMethod = method.trim();
this.logger.debug(`Bot API ${normalizedMethod} bot=@${bot.username}\n${formatTokenForLog(token)}`);
switch (normalizedMethod) {
case 'getMe':
return this.wrapOk(this.buildUserObject(bot));
case 'getChat':
return this.getChat(bot, payload as ChatPayload);
case 'sendMessage':
return this.sendMessage(bot, payload as SendMessagePayload);
case 'editMessageText':
return this.editMessageText(bot, payload as EditMessageTextPayload);
case 'editMessageReplyMarkup':
return this.editMessageReplyMarkup(bot, payload as EditMessageReplyMarkupPayload);
case 'pinChatMessage':
return this.setMessagePinned(bot, payload as PinChatMessagePayload, true);
case 'unpinChatMessage':
return this.setMessagePinned(bot, payload as ChatPayload, false);
case 'answerCallbackQuery':
return this.answerCallbackQuery(payload as AnswerCallbackQueryPayload);
case 'sendPhoto':
return this.sendPhoto(bot, payload as MediaPayload);
case 'sendDocument':
return this.sendDocument(bot, payload as MediaPayload);
case 'setWebhook':
return this.setWebhook(bot, payload as SetWebhookPayload);
case 'deleteWebhook':
return this.deleteWebhook(bot, payload);
case 'getWebhookInfo':
return this.getWebhookInfo(bot);
case 'getUpdates':
return this.getUpdates(bot, payload as GetUpdatesPayload);
case 'setChatMenuButton':
return this.setChatMenuButton(bot, payload as SetChatMenuButtonPayload);
default:
return {
httpStatus: 404,
responseJson: serializeTelegramResponse(telegramError(404, `Method '${normalizedMethod}' is not supported`))
};
}
}
validateWebAppInitData(initData: string, botToken: string) {
this.logger.debug(`Validate WebApp initData\n${formatTokenForLog(botToken)}`);
const result = this.webAppCrypto.validateWebAppData(initData, botToken);
if (!result.valid) {
return { valid: false, error: result.error };
}
return {
valid: true,
userJson: result.userJson,
authDate: result.authDate
};
}
async sendInternalBotMessage(
botId: string,
recipientUserId: string,
text: string,
replyMarkup?: Record<string, unknown> | null
) {
const bot = await this.prisma.bot.findUnique({
where: { id: botId },
select: { id: true, name: true, username: true, isActive: true }
});
if (!bot?.isActive) {
throw new Error('Системный бот недоступен');
}
const context = await this.resolveChatContext(bot.id, recipientUserId);
if (!context) {
throw new Error('Получатель не найден');
}
const parsedMarkup = this.serializer.parseReplyMarkup(replyMarkup ?? null);
const message = await this.createOutboundMessage(bot.id, context.chat.id, {
messageType: 'text',
text,
payload: this.serializer.buildStoredPayload(parsedMarkup)
});
await this.publishBotMessage(context.recipient?.id ?? null, bot as ValidatedBot, context.chat, message, parsedMarkup.internal);
return message;
}
async listBotChatMessages(userId: string, botRef: string, roomId?: string) {
const sender = await this.prisma.user.findUnique({
where: { id: userId },
select: { isSystemAccount: true, linkedBotId: true, deletedAt: true, status: true }
});
if (!sender || sender.deletedAt || sender.status !== 'ACTIVE') {
return { messages: [], botUsername: botRef, botDisplayName: 'Bot' };
}
assertHumanAccount(sender, { message: 'Системные учётные записи не могут просматривать чаты ботов' });
const bot = await this.prisma.bot.findFirst({
where: {
OR: [
{ id: botRef },
{ username: botRef },
{ username: `${botRef.replace(/_bot$/i, '')}_bot` }
]
},
select: {
id: true,
name: true,
username: true,
isActive: true,
isSystemBot: true,
webAppUrl: true,
menuButton: true,
ownerId: true
}
});
if (!bot?.isActive) {
return {
messages: [],
botUsername: botRef,
botDisplayName: 'Bot',
composerWebAppUrl: undefined,
composerMenuButtonJson: undefined
};
}
const privateChat = roomId
? await this.resolvePrivateBotChatForRoom(bot.id, userId, roomId)
: await this.prisma.botChat.findUnique({
where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } },
include: { menuButton: true }
});
const groupChat = roomId ? await this.ensureRoomBotChat(bot.id, roomId) : null;
const composer = this.resolveComposerForBot(bot, privateChat?.menuButton?.menuButton ?? null);
const emptyResponse = {
messages: [] as Array<Record<string, unknown>>,
botUsername: bot.username,
botDisplayName: bot.name,
botId: bot.id,
botOwnerId: bot.ownerId,
composerWebAppUrl: composer.composerWebAppUrl,
composerMenuButtonJson: composer.composerMenuButtonJson,
manageWebAppUrl: buildBotManageWebAppUrl(bot.id)
};
if (!privateChat && !groupChat) {
return emptyResponse;
}
const chatIds = [privateChat?.id, groupChat?.id].filter(Boolean) as string[];
const [outbound, inbound] = await Promise.all([
this.prisma.botMessage.findMany({
where: { botChatId: { in: chatIds } },
orderBy: { createdAt: 'asc' },
include: { botChat: { select: { chatType: true, roomId: true } } }
}),
privateChat
? this.prisma.botInboundMessage.findMany({
where: { botChatId: privateChat.id, senderUserId: userId },
orderBy: { createdAt: 'asc' }
})
: Promise.resolve([])
]);
const messages = [
...inbound.map((item) => ({
id: `in-${item.id}`,
direction: 'in' as const,
scope: 'private' as const,
text: item.text ?? '',
messageType: 'text',
messageId: item.telegramMsgId,
createdAt: item.createdAt.toISOString(),
replyMarkupJson: null as string | null,
isPinned: false,
pinnedAt: null as string | null
})),
...outbound.map((item) => {
const payload = this.serializer.readPayload(item.payload);
const replyMarkup = payload.telegramReplyMarkup ?? payload.replyMarkup;
const isBroadcast = item.botChat.chatType === 'group';
return {
id: `${isBroadcast ? 'broadcast' : 'out'}-${item.id}`,
direction: 'out' as const,
scope: isBroadcast ? ('broadcast' as const) : ('private' as const),
text: item.text ?? '',
messageType: item.messageType ?? 'text',
messageId: item.telegramMsgId,
createdAt: item.createdAt.toISOString(),
replyMarkupJson: replyMarkup ? JSON.stringify(replyMarkup) : null,
isPinned: item.isPinned,
pinnedAt: item.pinnedAt?.toISOString() ?? null
};
})
].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
return {
messages,
botUsername: bot.username,
botDisplayName: bot.name,
botId: bot.id,
botOwnerId: bot.ownerId,
composerWebAppUrl: composer.composerWebAppUrl,
composerMenuButtonJson: composer.composerMenuButtonJson,
manageWebAppUrl: buildBotManageWebAppUrl(bot.id)
};
}
private resolveComposerForBot(
bot: { isSystemBot: boolean; username: string; webAppUrl?: string | null; menuButton?: unknown },
chatOverride: unknown
) {
const resolved = resolveComposerMenuButton({
chatOverride,
globalMenuButton: bot.menuButton,
bot
});
return {
composerWebAppUrl: resolved.composerWebAppUrl,
composerMenuButtonJson: resolved.menuButton ? JSON.stringify(resolved.menuButton) : undefined,
buttonText: resolved.buttonText
};
}
private async resolvePrivateBotChatForRoom(botId: string, viewerUserId: string, roomId: string) {
const room = await this.prisma.chatRoom.findUnique({
where: { id: roomId },
include: {
members: { include: { user: { select: { linkedBotId: true } } } }
}
});
if (!room || room.type !== 'BOT' || !room.members.some((member) => member.userId === viewerUserId)) {
return null;
}
const botMember = room.members.find((member) => member.user.linkedBotId === botId);
if (!botMember || room.peerUserId !== botMember.userId) {
return null;
}
const existing = await this.prisma.botChat.findUnique({
where: { botId_recipientUserId: { botId, recipientUserId: viewerUserId } },
include: { menuButton: true }
});
if (existing) {
return existing;
}
const telegramChatId = deriveTelegramChatId(`${botId}:${viewerUserId}`);
try {
return await this.prisma.botChat.create({
data: {
botId,
recipientUserId: viewerUserId,
telegramChatId,
chatType: 'private'
},
include: { menuButton: true }
});
} catch {
return this.prisma.botChat.findUnique({
where: { botId_recipientUserId: { botId, recipientUserId: viewerUserId } },
include: { menuButton: true }
});
}
}
async ensureRoomBotChat(botId: string, roomId: string) {
const existing = await this.prisma.botChat.findUnique({
where: { botId_roomId: { botId, roomId } }
});
if (existing) {
return existing;
}
const telegramChatId = deriveTelegramChatId(`${botId}:room:${roomId}`);
try {
return await this.prisma.botChat.create({
data: {
botId,
roomId,
telegramChatId,
chatType: 'group',
recipientUserId: null
}
});
} catch {
const fallback = await this.prisma.botChat.findUnique({
where: { botId_roomId: { botId, roomId } }
});
if (!fallback) {
throw new Error('Не удалось создать групповой чат бота');
}
return fallback;
}
}
private async setChatMenuButton(bot: ValidatedBot, payload: SetChatMenuButtonPayload) {
const chatIdRaw = payload.chat_id;
const parsedMenuButton = parseMenuButtonInput(payload.menu_button);
const isGlobal =
chatIdRaw === undefined ||
chatIdRaw === null ||
(typeof chatIdRaw === 'string' && chatIdRaw.trim() === '');
if (isGlobal) {
const stored = menuButtonToJson(parsedMenuButton ?? null);
await this.prisma.bot.update({
where: { id: bot.id },
data: { menuButton: stored as never }
});
await this.botFather.invalidateBotCacheById(bot.id);
const chats = await this.prisma.botChat.findMany({
where: { botId: bot.id, recipientUserId: { not: null } },
include: { menuButton: true }
});
await Promise.all(
chats.map(async (chat) => {
if (!chat.recipientUserId) return;
const composer = this.resolveComposerForBot(
{ ...bot, menuButton: stored },
chat.menuButton?.menuButton ?? null
);
await this.publishMenuButtonUpdate(chat.recipientUserId, bot, composer);
})
);
return this.wrapOk(true);
}
const context = await this.resolveChatContext(bot.id, String(chatIdRaw));
if (!context) {
return this.wrapBadRequest('Bad Request: chat not found');
}
const shouldClear =
payload.menu_button === undefined ||
parsedMenuButton === null ||
parsedMenuButton === undefined ||
parsedMenuButton.type === 'default';
if (shouldClear) {
await this.prisma.chatMenuButton.deleteMany({ where: { botChatId: context.chat.id } });
} else {
const stored = menuButtonToJson(parsedMenuButton);
if (!stored) {
return this.wrapBadRequest('Bad Request: menu_button is invalid');
}
await this.prisma.chatMenuButton.upsert({
where: { botChatId: context.chat.id },
create: {
botId: bot.id,
botChatId: context.chat.id,
menuButton: stored as never
},
update: {
menuButton: stored as never
}
});
}
const freshBot = await this.prisma.bot.findUnique({
where: { id: bot.id },
select: { menuButton: true, isSystemBot: true, username: true, webAppUrl: true }
});
const override = shouldClear
? null
: (await this.prisma.chatMenuButton.findUnique({ where: { botChatId: context.chat.id } }))?.menuButton ?? null;
const composer = this.resolveComposerForBot(
{
isSystemBot: freshBot?.isSystemBot ?? bot.isSystemBot,
username: freshBot?.username ?? bot.username,
webAppUrl: freshBot?.webAppUrl ?? bot.webAppUrl,
menuButton: freshBot?.menuButton
},
override
);
if (context.recipient) {
await this.publishMenuButtonUpdate(context.recipient.id, bot, composer);
}
return this.wrapOk(true);
}
private async publishMenuButtonUpdate(
userId: string,
bot: ValidatedBot,
composer: { composerWebAppUrl?: string; composerMenuButtonJson?: string; buttonText?: string }
) {
await this.notifications.publishRealtime(userId, 'bot_menu_button_updated', bot.name, '', {
botId: bot.id,
botUsername: bot.username,
composerWebAppUrl: composer.composerWebAppUrl ?? null,
composerMenuButtonJson: composer.composerMenuButtonJson ?? null,
buttonText: composer.buttonText ?? null
});
}
private async sendMessage(bot: ValidatedBot, payload: SendMessagePayload) {
const chatIdRaw = payload.chat_id;
const text = payload.text?.trim();
if (chatIdRaw === undefined || chatIdRaw === null || chatIdRaw === '') {
return this.wrapBadRequest('Bad Request: chat_id is required');
}
if (!text) {
return this.wrapBadRequest('Bad Request: text is required');
}
const context = await this.resolveChatContext(bot.id, String(chatIdRaw));
if (!context) {
return this.wrapBadRequest('Bad Request: chat not found');
}
const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup);
const message = await this.createOutboundMessage(bot.id, context.chat.id, {
messageType: 'text',
text,
payload: this.serializer.buildStoredPayload(parsedMarkup)
});
await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal);
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
}
private async sendPhoto(bot: ValidatedBot, payload: MediaPayload) {
const photo = payload.photo?.trim();
if (!payload.chat_id || !photo) {
return this.wrapBadRequest('Bad Request: chat_id and photo are required');
}
const context = await this.resolveChatContext(bot.id, String(payload.chat_id));
if (!context) {
return this.wrapBadRequest('Bad Request: chat not found');
}
const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup);
const message = await this.createOutboundMessage(bot.id, context.chat.id, {
messageType: 'photo',
text: payload.caption?.trim() || null,
mediaUrl: photo,
payload: this.serializer.buildStoredPayload(parsedMarkup)
});
await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal, 'photo', photo);
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
}
private async sendDocument(bot: ValidatedBot, payload: MediaPayload) {
const document = payload.document?.trim();
if (!payload.chat_id || !document) {
return this.wrapBadRequest('Bad Request: chat_id and document are required');
}
const context = await this.resolveChatContext(bot.id, String(payload.chat_id));
if (!context) {
return this.wrapBadRequest('Bad Request: chat not found');
}
const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup);
const message = await this.createOutboundMessage(bot.id, context.chat.id, {
messageType: 'document',
text: payload.caption?.trim() || null,
mediaUrl: document,
payload: this.serializer.buildStoredPayload(parsedMarkup)
});
await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal, 'document', document);
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
}
private async editMessageText(bot: ValidatedBot, payload: EditMessageTextPayload) {
const text = payload.text?.trim();
if (!payload.chat_id || payload.message_id === undefined) {
return this.wrapBadRequest('Bad Request: chat_id and message_id are required');
}
if (!text) {
return this.wrapBadRequest('Bad Request: text is required');
}
const messageId = Number(payload.message_id);
if (!Number.isFinite(messageId)) {
return this.wrapBadRequest('Bad Request: message_id is invalid');
}
const located = await this.locateMessage(bot.id, String(payload.chat_id), messageId);
if (!located) {
return this.wrapBadRequest('Bad Request: message to edit not found');
}
const parsedMarkup = payload.reply_markup
? this.serializer.parseReplyMarkup(payload.reply_markup)
: this.serializer.readPayloadAsParsed(located.message.payload);
const updated = await this.prisma.botMessage.update({
where: { id: located.message.id },
data: {
text,
editedAt: new Date(),
payload: this.mergePayload(located.message.payload, parsedMarkup)
}
});
await this.publishBotMessageEdited(located.recipient?.id ?? null, bot, located.chat, updated, parsedMarkup.internal);
return this.wrapOk(this.buildTelegramMessage(bot, located, updated));
}
private async editMessageReplyMarkup(bot: ValidatedBot, payload: EditMessageReplyMarkupPayload) {
if (!payload.chat_id || payload.message_id === undefined || payload.reply_markup === undefined) {
return this.wrapBadRequest('Bad Request: chat_id, message_id and reply_markup are required');
}
const messageId = Number(payload.message_id);
if (!Number.isFinite(messageId)) {
return this.wrapBadRequest('Bad Request: message_id is invalid');
}
const located = await this.locateMessage(bot.id, String(payload.chat_id), messageId);
if (!located) {
return this.wrapBadRequest('Bad Request: message to edit not found');
}
const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup);
const updated = await this.prisma.botMessage.update({
where: { id: located.message.id },
data: {
editedAt: new Date(),
payload: this.mergePayload(located.message.payload, parsedMarkup)
}
});
await this.publishBotMessageEdited(located.recipient?.id ?? null, bot, located.chat, updated, parsedMarkup.internal);
return this.wrapOk(this.buildTelegramMessage(bot, located, updated));
}
private async getChat(bot: ValidatedBot, payload: ChatPayload) {
if (!payload.chat_id) {
return this.wrapBadRequest('Bad Request: chat_id is required');
}
const context = await this.resolveChatContext(bot.id, String(payload.chat_id));
if (!context) {
return this.wrapBadRequest('Bad Request: chat not found');
}
const pinned = await this.prisma.botMessage.findFirst({
where: {
botId: bot.id,
botChatId: context.chat.id,
isPinned: true
},
orderBy: { pinnedAt: 'desc' }
});
return this.wrapOk({
id: Number(context.chat.telegramChatId),
type: context.chat.chatType,
first_name: context.recipient?.displayName ?? bot.name,
...(context.recipient?.username ? { username: context.recipient.username } : {}),
...(pinned ? { pinned_message: this.buildTelegramMessage(bot, context, pinned) } : {})
});
}
private async setMessagePinned(bot: ValidatedBot, payload: ChatPayload, pinned: boolean) {
if (!payload.chat_id) {
return this.wrapBadRequest('Bad Request: chat_id is required');
}
if (pinned && payload.message_id === undefined) {
return this.wrapBadRequest('Bad Request: message_id is required');
}
const messageId = payload.message_id === undefined ? undefined : Number(payload.message_id);
if (messageId !== undefined && !Number.isFinite(messageId)) {
return this.wrapBadRequest('Bad Request: message_id is invalid');
}
const context = await this.resolveChatContext(bot.id, String(payload.chat_id));
if (!context) {
return this.wrapBadRequest('Bad Request: chat not found');
}
const message = messageId === undefined
? await this.prisma.botMessage.findFirst({
where: {
botId: bot.id,
botChatId: context.chat.id,
isPinned: true
},
orderBy: { pinnedAt: 'desc' }
})
: await this.prisma.botMessage.findFirst({
where: {
botId: bot.id,
botChatId: context.chat.id,
telegramMsgId: messageId
}
});
if (!message) {
return this.wrapBadRequest(pinned ? 'Bad Request: message to pin not found' : 'Bad Request: message to unpin not found');
}
const updated = await this.prisma.botMessage.update({
where: { id: message.id },
data: pinned ? { isPinned: true, pinnedAt: new Date() } : { isPinned: false, pinnedAt: null }
});
await this.publishBotMessagePinned(context.recipient?.id ?? null, bot, context.chat, updated);
return this.wrapOk(true);
}
private async answerCallbackQuery(payload: AnswerCallbackQueryPayload) {
const callbackQueryId = payload.callback_query_id?.trim();
if (!callbackQueryId) {
return this.wrapBadRequest('Bad Request: callback_query_id is required');
}
const registered = await this.callbackRegistry.resolve(callbackQueryId);
if (!registered) {
return this.wrapBadRequest('Bad Request: callback query not found or expired');
}
await this.notifications.publishRealtime(registered.userId, 'bot_callback_answer', '', payload.text?.trim() ?? '', {
callbackQueryId,
text: payload.text?.trim() ?? null,
showAlert: Boolean(payload.show_alert),
url: payload.url?.trim() ?? null,
botId: registered.botId
});
return this.wrapOk(true);
}
private async setWebhook(bot: ValidatedBot, payload: SetWebhookPayload) {
const url = payload.url?.trim();
if (!url) {
return this.wrapBadRequest('Bad Request: url is required');
}
if (!/^https?:\/\//i.test(url)) {
return this.wrapBadRequest('Bad Request: invalid webhook url');
}
if (payload.drop_pending_updates) {
await this.updateQueue.clearQueue(bot.id);
await this.prisma.botUpdate.deleteMany({ where: { botId: bot.id } });
}
await this.prisma.bot.update({
where: { id: bot.id },
data: {
webhookUrl: url,
webhookSecretToken: payload.secret_token?.trim() || null
}
});
await this.botFather.invalidateBotCacheById(bot.id);
return this.wrapOk(true);
}
private async deleteWebhook(bot: ValidatedBot, payload: Record<string, unknown>) {
if (payload.drop_pending_updates) {
await this.updateQueue.clearQueue(bot.id);
await this.prisma.botUpdate.deleteMany({ where: { botId: bot.id } });
}
await this.prisma.bot.update({
where: { id: bot.id },
data: {
webhookUrl: null,
webhookSecretToken: null
}
});
await this.botFather.invalidateBotCacheById(bot.id);
return this.wrapOk(true);
}
private async getWebhookInfo(bot: ValidatedBot) {
const record = await this.prisma.bot.findUnique({
where: { id: bot.id },
select: { webhookUrl: true }
});
const pending = record?.webhookUrl ? 0 : await this.updateQueue.pendingCount(bot.id);
return this.wrapOk({
url: record?.webhookUrl ?? '',
has_custom_certificate: false,
pending_update_count: pending,
max_connections: 40
});
}
private async getUpdates(bot: ValidatedBot, payload: GetUpdatesPayload) {
const record = await this.prisma.bot.findUnique({
where: { id: bot.id },
select: { webhookUrl: true }
});
if (record?.webhookUrl) {
return this.wrapBadRequest("Bad Request: can't use getUpdates while webhook is active; use deleteWebhook first");
}
const offset = payload.offset !== undefined ? Number(payload.offset) : 0;
const limit = payload.limit !== undefined ? Number(payload.limit) : 100;
const timeout = payload.timeout !== undefined ? Number(payload.timeout) : 0;
if (!Number.isFinite(offset) || offset < 0) {
return this.wrapBadRequest('Bad Request: offset is invalid');
}
if (!Number.isFinite(limit) || limit <= 0) {
return this.wrapBadRequest('Bad Request: limit is invalid');
}
if (!Number.isFinite(timeout) || timeout < 0) {
return this.wrapBadRequest('Bad Request: timeout is invalid');
}
const updates = await this.updateQueue.getUpdates(bot.id, offset, limit, timeout);
return this.wrapOk(updates);
}
private async createOutboundMessage(
botId: string,
botChatId: string,
data: {
messageType: string;
text?: string | null;
mediaUrl?: string | null;
payload?: Record<string, unknown>;
}
) {
return this.prisma.$transaction(async (tx) => {
const currentBot = await tx.bot.update({
where: { id: botId },
data: { nextMessageId: { increment: 1 } },
select: { nextMessageId: true }
});
const telegramMsgId = currentBot.nextMessageId - 1;
return tx.botMessage.create({
data: {
botId,
botChatId,
telegramMsgId,
messageType: data.messageType,
text: data.text ?? null,
mediaUrl: data.mediaUrl ?? null,
payload: data.payload as never
}
});
});
}
private async locateMessage(botId: string, chatIdRaw: string, telegramMsgId: number) {
const context = await this.resolveChatContext(botId, chatIdRaw);
if (!context) return null;
const message = await this.prisma.botMessage.findFirst({
where: {
botId,
botChatId: context.chat.id,
telegramMsgId
}
});
if (!message) return null;
return { ...context, message };
}
private async resolveChatContext(botId: string, chatIdRaw: string) {
const numericChatId = chatIdRaw.match(/^\d+$/) ? BigInt(chatIdRaw) : null;
if (numericChatId) {
const chat = await this.prisma.botChat.findUnique({
where: { botId_telegramChatId: { botId, telegramChatId: numericChatId } }
});
if (!chat) {
return null;
}
if (chat.chatType === 'group') {
return { recipient: null, chat };
}
if (!chat.recipientUserId) {
return null;
}
const recipient = await this.prisma.user.findUnique({
where: { id: chat.recipientUserId },
select: { id: true, displayName: true, username: true, status: true, deletedAt: true }
});
if (!recipient || recipient.deletedAt || recipient.status !== 'ACTIVE') {
return null;
}
return { recipient, chat };
}
const recipient = await this.resolveRecipient(chatIdRaw, botId);
if (!recipient) {
return null;
}
const chat = await this.ensureBotChat(botId, recipient.id, chatIdRaw);
return { recipient, chat };
}
private buildTelegramMessage(
bot: ValidatedBot,
context: {
recipient: { id: string; displayName: string; username: string | null } | null;
chat: { telegramChatId: bigint; chatType: string };
},
message: {
telegramMsgId: number;
text?: string | null;
messageType?: string | null;
mediaUrl?: string | null;
createdAt: Date;
editedAt?: Date | null;
payload?: unknown;
isPinned?: boolean;
pinnedAt?: Date | null;
}
) {
return this.serializer.buildBotMessageObject({
message,
bot,
chat: {
telegramChatId: context.chat.telegramChatId,
chatType: context.chat.chatType,
recipientDisplayName: context.recipient?.displayName ?? bot.name,
recipientUsername: context.recipient?.username ?? null
}
});
}
private async publishBotMessage(
userId: string | null,
bot: ValidatedBot,
chat: { telegramChatId: bigint; chatType: string; roomId?: string | null },
message: {
telegramMsgId: number;
text?: string | null;
messageType?: string | null;
mediaUrl?: string | null;
payload?: unknown;
},
replyMarkup: unknown,
mediaType?: string,
mediaUrl?: string
) {
const payloadMarkup = this.serializer.readPayload(message.payload);
const payload = {
botId: bot.id,
botUsername: bot.username,
chatId: chat.telegramChatId.toString(),
messageId: message.telegramMsgId,
text: message.text ?? null,
messageType: message.messageType ?? mediaType ?? 'text',
mediaUrl: message.mediaUrl ?? mediaUrl ?? null,
replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null,
telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null,
scope: chat.chatType === 'group' ? 'broadcast' : 'private',
roomId: chat.roomId ?? null
};
if (chat.chatType === 'group' && chat.roomId) {
await this.publishToRoomMembers(chat.roomId, bot, 'bot_message', message.text ?? '', payload);
return;
}
if (userId) {
await this.notifications.publishRealtime(userId, 'bot_message', bot.name, message.text ?? '', payload);
}
}
private async publishBotMessagePinned(
userId: string | null,
bot: ValidatedBot,
chat: { telegramChatId: bigint; chatType: string; roomId?: string | null },
message: {
telegramMsgId: number;
isPinned?: boolean;
pinnedAt?: Date | null;
}
) {
const payload = {
botId: bot.id,
botUsername: bot.username,
chatId: chat.telegramChatId.toString(),
messageId: message.telegramMsgId,
isPinned: Boolean(message.isPinned),
pinnedAt: message.pinnedAt?.toISOString() ?? null,
scope: chat.chatType === 'group' ? 'broadcast' : 'private',
roomId: chat.roomId ?? null
};
if (chat.chatType === 'group' && chat.roomId) {
await this.publishToRoomMembers(chat.roomId, bot, 'bot_message_pinned', '', payload);
return;
}
if (userId) {
await this.notifications.publishRealtime(userId, 'bot_message_pinned', bot.name, '', payload);
}
}
private async publishBotMessageEdited(
userId: string | null,
bot: ValidatedBot,
chat: { telegramChatId: bigint; chatType: string; roomId?: string | null },
message: {
telegramMsgId: number;
text?: string | null;
messageType?: string | null;
mediaUrl?: string | null;
payload?: unknown;
editedAt?: Date | null;
},
replyMarkup: unknown
) {
const payloadMarkup = this.serializer.readPayload(message.payload);
const payload = {
botId: bot.id,
botUsername: bot.username,
chatId: chat.telegramChatId.toString(),
messageId: message.telegramMsgId,
text: message.text ?? null,
messageType: message.messageType ?? 'text',
mediaUrl: message.mediaUrl ?? null,
replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null,
telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null,
editedAt: message.editedAt?.toISOString() ?? new Date().toISOString(),
scope: chat.chatType === 'group' ? 'broadcast' : 'private',
roomId: chat.roomId ?? null
};
if (chat.chatType === 'group' && chat.roomId) {
await this.publishToRoomMembers(chat.roomId, bot, 'bot_message_edited', message.text ?? '', payload);
return;
}
if (userId) {
await this.notifications.publishRealtime(userId, 'bot_message_edited', bot.name, message.text ?? '', payload);
}
}
private async publishToRoomMembers(
roomId: string,
bot: ValidatedBot,
eventType: string,
message: string,
payload: Record<string, unknown>
) {
const room = await this.prisma.chatRoom.findUnique({
where: { id: roomId },
include: {
members: { include: { user: { select: { linkedBotId: true } } } }
}
});
if (!room || room.type !== 'BOT') {
return;
}
const recipients = room.members
.filter((member) => !member.user.linkedBotId)
.map((member) => member.userId);
await Promise.all(
recipients.map((recipientId) =>
this.notifications.publishRealtime(recipientId, eventType, bot.name, message, payload)
)
);
}
private mergePayload(existing: unknown, parsed: ReturnType<TelegramSerializerService['parseReplyMarkup']>) {
const current = existing && typeof existing === 'object' ? (existing as Record<string, unknown>) : {};
const stored = this.serializer.buildStoredPayload(parsed);
return (stored ? { ...current, ...stored } : current) as never;
}
private async resolveRecipient(chatId: string, botId: string) {
const numericChatId = chatId.match(/^\d+$/) ? BigInt(chatId) : null;
if (numericChatId) {
const chat = await this.prisma.botChat.findUnique({
where: { botId_telegramChatId: { botId, telegramChatId: numericChatId } }
});
if (!chat?.recipientUserId) return null;
return this.prisma.user.findUnique({
where: { id: chat.recipientUserId },
select: { id: true, displayName: true, username: true, status: true, deletedAt: true }
});
}
const user = await this.prisma.user.findUnique({
where: { id: chatId },
select: { id: true, displayName: true, username: true, status: true, deletedAt: true }
});
if (!user || user.deletedAt || user.status !== 'ACTIVE') {
return null;
}
return user;
}
private async ensureBotChat(botId: string, recipientUserId: string, chatIdRaw: string) {
const existing = await this.prisma.botChat.findUnique({
where: { botId_recipientUserId: { botId, recipientUserId } }
});
if (existing) return existing;
const telegramChatId = chatIdRaw.match(/^\d+$/) ? BigInt(chatIdRaw) : deriveTelegramChatId(`${botId}:${recipientUserId}`);
try {
return await this.prisma.botChat.create({
data: {
botId,
recipientUserId,
telegramChatId,
chatType: 'private'
}
});
} catch {
const fallback = await this.prisma.botChat.findUnique({
where: { botId_recipientUserId: { botId, recipientUserId } }
});
if (!fallback) {
throw new Error('Не удалось создать чат бота');
}
return fallback;
}
}
private buildUserObject(bot: ValidatedBot) {
return {
id: Number(deriveTelegramChatId(bot.id)),
is_bot: true,
first_name: bot.name,
username: bot.username,
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false
};
}
private wrapOk(result: unknown) {
return {
httpStatus: 200,
responseJson: serializeTelegramResponse(telegramOk(result))
};
}
private wrapBadRequest(description: string) {
return {
httpStatus: 200,
responseJson: serializeTelegramResponse(telegramError(400, description))
};
}
}