1186 lines
39 KiB
TypeScript
1186 lines
39 KiB
TypeScript
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))
|
||
};
|
||
}
|
||
}
|