Files
Tobias Lins 0c688a0111 Add support for telegram bot (#90)
* Add support for telegram bot

* Add support for action buttons in telegram

* Use markdown parse mode

* Add more tests

* Fix linting issues

* Fixes

* Fixes

* Don't reverse order messages for AI and always answer for telegram

* Add telegram integration test

* Update telegram.json
2026-02-26 14:10:41 -08:00

1363 lines
36 KiB
TypeScript

import {
AdapterRateLimitError,
AuthenticationError,
cardToFallbackText,
extractCard,
extractFiles,
NetworkError,
PermissionError,
ResourceNotFoundError,
ValidationError,
} from "@chat-adapter/shared";
import type {
Adapter,
AdapterPostableMessage,
Attachment,
ChannelInfo,
ChatInstance,
EmojiValue,
FetchOptions,
FetchResult,
FormattedContent,
Logger,
RawMessage,
ThreadInfo,
WebhookOptions,
} from "chat";
import {
ConsoleLogger,
convertEmojiPlaceholders,
defaultEmojiResolver,
getEmoji,
Message,
NotImplementedError,
} from "chat";
import {
cardToTelegramInlineKeyboard,
decodeTelegramCallbackData,
emptyTelegramInlineKeyboard,
} from "./cards";
import { TelegramFormatConverter } from "./markdown";
import type {
TelegramAdapterConfig,
TelegramApiResponse,
TelegramCallbackQuery,
TelegramChat,
TelegramFile,
TelegramInlineKeyboardMarkup,
TelegramMessage,
TelegramMessageEntity,
TelegramMessageReactionUpdated,
TelegramRawMessage,
TelegramReactionType,
TelegramThreadId,
TelegramUpdate,
TelegramUser,
} from "./types";
const TELEGRAM_API_BASE = "https://api.telegram.org";
const TELEGRAM_MESSAGE_LIMIT = 4096;
const TELEGRAM_CAPTION_LIMIT = 1024;
const TELEGRAM_SECRET_TOKEN_HEADER = "x-telegram-bot-api-secret-token";
const MESSAGE_ID_PATTERN = /^([^:]+):(\d+)$/;
const TELEGRAM_MARKDOWN_PARSE_MODE = "Markdown";
const TRAILING_SLASHES_REGEX = /\/+$/;
const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/;
const LEADING_AT_PATTERN = /^@+/;
const EMOJI_PLACEHOLDER_PATTERN = /^\{\{emoji:([a-z0-9_]+)\}\}$/i;
const EMOJI_NAME_PATTERN = /^[a-z0-9_+-]+$/i;
interface TelegramMessageAuthor {
fullName: string;
isBot: boolean | "unknown";
isMe: boolean;
userId: string;
userName: string;
}
export class TelegramAdapter
implements Adapter<TelegramThreadId, TelegramRawMessage>
{
readonly name = "telegram";
private readonly botToken: string;
private readonly apiBaseUrl: string;
private readonly secretToken?: string;
private readonly logger: Logger;
private readonly formatConverter = new TelegramFormatConverter();
private readonly messageCache = new Map<
string,
Message<TelegramRawMessage>[]
>();
private chat: ChatInstance | null = null;
private _botUserId?: string;
private _userName: string;
private readonly hasExplicitUserName: boolean;
get botUserId(): string | undefined {
return this._botUserId;
}
get userName(): string {
return this._userName;
}
constructor(
config: TelegramAdapterConfig & { logger: Logger; userName?: string }
) {
this.botToken = config.botToken;
this.apiBaseUrl = (config.apiBaseUrl ?? TELEGRAM_API_BASE).replace(
TRAILING_SLASHES_REGEX,
""
);
this.secretToken = config.secretToken;
this.logger = config.logger;
this._userName = this.normalizeUserName(config.userName ?? "bot");
this.hasExplicitUserName = Boolean(config.userName);
}
async initialize(chat: ChatInstance): Promise<void> {
this.chat = chat;
if (!this.hasExplicitUserName) {
this._userName = this.normalizeUserName(chat.getUserName());
}
try {
const me = await this.telegramFetch<TelegramUser>("getMe");
this._botUserId = String(me.id);
if (!this.hasExplicitUserName && me.username) {
this._userName = this.normalizeUserName(me.username);
}
this.logger.info("Telegram adapter initialized", {
botUserId: this._botUserId,
userName: this._userName,
});
} catch (error) {
this.logger.warn("Failed to fetch Telegram bot identity", {
error: String(error),
});
}
}
async handleWebhook(
request: Request,
options?: WebhookOptions
): Promise<Response> {
if (this.secretToken) {
const headerToken = request.headers.get(TELEGRAM_SECRET_TOKEN_HEADER);
if (headerToken !== this.secretToken) {
this.logger.warn(
"Telegram webhook rejected due to invalid secret token"
);
return new Response("Invalid secret token", { status: 401 });
}
}
let update: TelegramUpdate;
try {
update = (await request.json()) as TelegramUpdate;
} catch {
return new Response("Invalid JSON", { status: 400 });
}
if (!this.chat) {
this.logger.warn(
"Chat instance not initialized, ignoring Telegram webhook"
);
return new Response("OK", { status: 200 });
}
const messageUpdate =
update.message ??
update.edited_message ??
update.channel_post ??
update.edited_channel_post;
if (messageUpdate) {
this.handleIncomingMessageUpdate(messageUpdate, options);
}
if (update.callback_query) {
this.handleCallbackQuery(update.callback_query, options);
}
if (update.message_reaction) {
this.handleMessageReactionUpdate(update.message_reaction, options);
}
return new Response("OK", { status: 200 });
}
private handleIncomingMessageUpdate(
telegramMessage: TelegramMessage,
options?: WebhookOptions
): void {
if (!this.chat) {
return;
}
const threadId = this.encodeThreadId({
chatId: String(telegramMessage.chat.id),
messageThreadId: telegramMessage.message_thread_id,
});
const parsedMessage = this.parseTelegramMessage(telegramMessage, threadId);
this.cacheMessage(parsedMessage);
this.chat.processMessage(this, threadId, parsedMessage, options);
}
private handleCallbackQuery(
callbackQuery: TelegramCallbackQuery,
options?: WebhookOptions
): void {
if (!(this.chat && callbackQuery.message)) {
return;
}
const threadId = this.encodeThreadId({
chatId: String(callbackQuery.message.chat.id),
messageThreadId: callbackQuery.message.message_thread_id,
});
const messageId = this.encodeMessageId(
String(callbackQuery.message.chat.id),
callbackQuery.message.message_id
);
const { actionId, value } = decodeTelegramCallbackData(callbackQuery.data);
this.chat.processAction(
{
adapter: this,
actionId,
value,
messageId,
threadId,
user: this.toAuthor(callbackQuery.from),
raw: callbackQuery,
},
options
);
const ackTask = this.telegramFetch<boolean>("answerCallbackQuery", {
callback_query_id: callbackQuery.id,
}).catch((error) => {
this.logger.warn("Failed to acknowledge Telegram callback query", {
callbackQueryId: callbackQuery.id,
error: String(error),
});
});
if (options?.waitUntil) {
options.waitUntil(ackTask);
}
}
private handleMessageReactionUpdate(
reactionUpdate: TelegramMessageReactionUpdated,
options?: WebhookOptions
): void {
if (!this.chat) {
return;
}
const threadId = this.encodeThreadId({
chatId: String(reactionUpdate.chat.id),
messageThreadId: reactionUpdate.message_thread_id,
});
const messageId = this.encodeMessageId(
String(reactionUpdate.chat.id),
reactionUpdate.message_id
);
const oldReactions = new Set(
reactionUpdate.old_reaction.map((reaction) => this.reactionKey(reaction))
);
const newReactions = new Set(
reactionUpdate.new_reaction.map((reaction) => this.reactionKey(reaction))
);
const actor = reactionUpdate.user
? this.toAuthor(reactionUpdate.user)
: this.toReactionActorAuthor(reactionUpdate.chat);
for (const reaction of reactionUpdate.new_reaction) {
const key = this.reactionKey(reaction);
if (!oldReactions.has(key)) {
this.chat.processReaction(
{
adapter: this,
threadId,
messageId,
emoji: this.reactionToEmojiValue(reaction),
rawEmoji: key,
added: true,
user: actor,
raw: reactionUpdate,
},
options
);
}
}
for (const reaction of reactionUpdate.old_reaction) {
const key = this.reactionKey(reaction);
if (!newReactions.has(key)) {
this.chat.processReaction(
{
adapter: this,
threadId,
messageId,
emoji: this.reactionToEmojiValue(reaction),
rawEmoji: key,
added: false,
user: actor,
raw: reactionUpdate,
},
options
);
}
}
}
async postMessage(
threadId: string,
message: AdapterPostableMessage
): Promise<RawMessage<TelegramRawMessage>> {
const parsedThread = this.resolveThreadId(threadId);
const card = extractCard(message);
const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined;
const parseMode = card ? TELEGRAM_MARKDOWN_PARSE_MODE : undefined;
const text = this.truncateMessage(
convertEmojiPlaceholders(
card
? cardToFallbackText(card)
: this.formatConverter.renderPostable(message),
"gchat"
)
);
const files = extractFiles(message);
if (files.length > 1) {
throw new ValidationError(
"telegram",
"Telegram adapter supports a single file upload per message"
);
}
let rawMessage: TelegramMessage;
if (files.length === 1) {
const [file] = files;
if (!file) {
throw new ValidationError("telegram", "File upload payload is empty");
}
rawMessage = await this.sendDocument(
parsedThread,
file,
text,
replyMarkup,
parseMode
);
} else {
if (!text.trim()) {
throw new ValidationError("telegram", "Message text cannot be empty");
}
rawMessage = await this.telegramFetch<TelegramMessage>("sendMessage", {
chat_id: parsedThread.chatId,
message_thread_id: parsedThread.messageThreadId,
text,
reply_markup: replyMarkup,
parse_mode: parseMode,
});
}
const resultingThreadId = this.encodeThreadId({
chatId: String(rawMessage.chat.id),
messageThreadId:
rawMessage.message_thread_id ?? parsedThread.messageThreadId,
});
const parsedMessage = this.parseTelegramMessage(
rawMessage,
resultingThreadId
);
this.cacheMessage(parsedMessage);
return {
id: parsedMessage.id,
threadId: parsedMessage.threadId,
raw: rawMessage,
};
}
async postChannelMessage(
channelId: string,
message: AdapterPostableMessage
): Promise<RawMessage<TelegramRawMessage>> {
const threadId = this.encodeThreadId({ chatId: channelId });
return this.postMessage(threadId, message);
}
async editMessage(
threadId: string,
messageId: string,
message: AdapterPostableMessage
): Promise<RawMessage<TelegramRawMessage>> {
const parsedThread = this.resolveThreadId(threadId);
const {
chatId,
messageId: telegramMessageId,
compositeId,
} = this.decodeCompositeMessageId(messageId, parsedThread.chatId);
const card = extractCard(message);
const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined;
const parseMode = card ? TELEGRAM_MARKDOWN_PARSE_MODE : undefined;
const text = this.truncateMessage(
convertEmojiPlaceholders(
card
? cardToFallbackText(card)
: this.formatConverter.renderPostable(message),
"gchat"
)
);
if (!text.trim()) {
throw new ValidationError("telegram", "Message text cannot be empty");
}
const result = await this.telegramFetch<TelegramMessage | true>(
"editMessageText",
{
chat_id: chatId,
message_id: telegramMessageId,
text,
reply_markup: replyMarkup ?? emptyTelegramInlineKeyboard(),
parse_mode: parseMode,
}
);
if (result === true) {
const existing = this.findCachedMessage(compositeId);
if (!existing) {
throw new NotImplementedError(
"Telegram returned a non-message edit result and no cached message was found",
"editMessage"
);
}
const updated = new Message<TelegramRawMessage>({
...existing,
text,
formatted: this.formatConverter.toAst(text),
metadata: {
...existing.metadata,
edited: true,
editedAt: new Date(),
},
});
this.cacheMessage(updated);
return {
id: updated.id,
threadId: updated.threadId,
raw: updated.raw,
};
}
const resultingThreadId = this.encodeThreadId({
chatId: String(result.chat.id),
messageThreadId: result.message_thread_id ?? parsedThread.messageThreadId,
});
const parsedMessage = this.parseTelegramMessage(result, resultingThreadId);
this.cacheMessage(parsedMessage);
return {
id: parsedMessage.id,
threadId: parsedMessage.threadId,
raw: result,
};
}
async deleteMessage(threadId: string, messageId: string): Promise<void> {
const parsedThread = this.resolveThreadId(threadId);
const {
chatId,
messageId: telegramMessageId,
compositeId,
} = this.decodeCompositeMessageId(messageId, parsedThread.chatId);
await this.telegramFetch<boolean>("deleteMessage", {
chat_id: chatId,
message_id: telegramMessageId,
});
this.deleteCachedMessage(compositeId);
}
async addReaction(
threadId: string,
messageId: string,
emoji: EmojiValue | string
): Promise<void> {
const parsedThread = this.resolveThreadId(threadId);
const { chatId, messageId: telegramMessageId } =
this.decodeCompositeMessageId(messageId, parsedThread.chatId);
await this.telegramFetch<boolean>("setMessageReaction", {
chat_id: chatId,
message_id: telegramMessageId,
reaction: [this.toTelegramReaction(emoji)],
});
}
async removeReaction(
threadId: string,
messageId: string,
_emoji: EmojiValue | string
): Promise<void> {
const parsedThread = this.resolveThreadId(threadId);
const { chatId, messageId: telegramMessageId } =
this.decodeCompositeMessageId(messageId, parsedThread.chatId);
await this.telegramFetch<boolean>("setMessageReaction", {
chat_id: chatId,
message_id: telegramMessageId,
reaction: [],
});
}
async startTyping(threadId: string): Promise<void> {
const parsedThread = this.resolveThreadId(threadId);
await this.telegramFetch<boolean>("sendChatAction", {
chat_id: parsedThread.chatId,
message_thread_id: parsedThread.messageThreadId,
action: "typing",
});
}
async fetchMessages(
threadId: string,
options: FetchOptions = {}
): Promise<FetchResult<TelegramRawMessage>> {
const messages = [...(this.messageCache.get(threadId) ?? [])].sort((a, b) =>
this.compareMessages(a, b)
);
return this.paginateMessages(messages, options);
}
async fetchChannelMessages(
channelId: string,
options: FetchOptions = {}
): Promise<FetchResult<TelegramRawMessage>> {
const byId = new Map<string, Message<TelegramRawMessage>>();
for (const [threadId, messages] of this.messageCache.entries()) {
let decoded: TelegramThreadId;
try {
decoded = this.decodeThreadId(threadId);
} catch {
continue;
}
if (decoded.chatId !== channelId) {
continue;
}
for (const message of messages) {
byId.set(message.id, message);
}
}
const allMessages = [...byId.values()].sort((a, b) =>
this.compareMessages(a, b)
);
return this.paginateMessages(allMessages, options);
}
async fetchMessage(
_threadId: string,
messageId: string
): Promise<Message<TelegramRawMessage> | null> {
return this.findCachedMessage(messageId) ?? null;
}
async fetchThread(threadId: string): Promise<ThreadInfo> {
const parsedThread = this.resolveThreadId(threadId);
const chat = await this.telegramFetch<TelegramChat>("getChat", {
chat_id: parsedThread.chatId,
});
return {
id: this.encodeThreadId(parsedThread),
channelId: String(chat.id),
channelName: this.chatDisplayName(chat),
isDM: chat.type === "private",
metadata: {
chat,
messageThreadId: parsedThread.messageThreadId,
},
};
}
async fetchChannelInfo(channelId: string): Promise<ChannelInfo> {
const chat = await this.telegramFetch<TelegramChat>("getChat", {
chat_id: channelId,
});
let memberCount: number | undefined;
try {
memberCount = await this.telegramFetch<number>("getChatMemberCount", {
chat_id: channelId,
});
} catch {
// Some chats disallow member count queries for bot scopes.
memberCount = undefined;
}
return {
id: String(chat.id),
name: this.chatDisplayName(chat),
isDM: chat.type === "private",
memberCount,
metadata: {
chat,
},
};
}
channelIdFromThreadId(threadId: string): string {
return this.resolveThreadId(threadId).chatId;
}
async openDM(userId: string): Promise<string> {
return this.encodeThreadId({ chatId: userId });
}
isDM(threadId: string): boolean {
const { chatId } = this.resolveThreadId(threadId);
return !chatId.startsWith("-");
}
encodeThreadId(platformData: TelegramThreadId): string {
if (typeof platformData.messageThreadId === "number") {
return `telegram:${platformData.chatId}:${platformData.messageThreadId}`;
}
return `telegram:${platformData.chatId}`;
}
decodeThreadId(threadId: string): TelegramThreadId {
const parts = threadId.split(":");
if (parts[0] !== "telegram" || parts.length < 2 || parts.length > 3) {
throw new ValidationError(
"telegram",
`Invalid Telegram thread ID: ${threadId}`
);
}
const chatId = parts[1];
if (!chatId) {
throw new ValidationError(
"telegram",
`Invalid Telegram thread ID: ${threadId}`
);
}
const messageThreadPart = parts[2];
if (!messageThreadPart) {
return { chatId };
}
const messageThreadId = Number.parseInt(messageThreadPart, 10);
if (!Number.isFinite(messageThreadId)) {
throw new ValidationError(
"telegram",
`Invalid Telegram thread topic ID in thread ID: ${threadId}`
);
}
return {
chatId,
messageThreadId,
};
}
parseMessage(raw: TelegramRawMessage): Message<TelegramRawMessage> {
const threadId = this.encodeThreadId({
chatId: String(raw.chat.id),
messageThreadId: raw.message_thread_id,
});
const message = this.parseTelegramMessage(raw, threadId);
this.cacheMessage(message);
return message;
}
renderFormatted(content: FormattedContent): string {
return this.formatConverter.fromAst(content);
}
private parseTelegramMessage(
raw: TelegramMessage,
threadId: string
): Message<TelegramRawMessage> {
const text = raw.text ?? raw.caption ?? "";
let author: TelegramMessageAuthor;
if (raw.from) {
author = this.toAuthor(raw.from);
} else if (raw.sender_chat) {
author = this.toReactionActorAuthor(raw.sender_chat);
} else {
const fallbackName =
this.chatDisplayName(raw.chat) ?? String(raw.chat.id);
author = {
userId: String(raw.chat.id),
userName: fallbackName,
fullName: fallbackName,
isBot: "unknown" as const,
isMe: false,
};
}
const message = new Message<TelegramRawMessage>({
id: this.encodeMessageId(String(raw.chat.id), raw.message_id),
threadId,
text,
formatted: this.formatConverter.toAst(text),
raw,
author,
metadata: {
dateSent: new Date(raw.date * 1000),
edited: raw.edit_date !== undefined,
editedAt:
raw.edit_date !== undefined
? new Date(raw.edit_date * 1000)
: undefined,
},
attachments: this.extractAttachments(raw),
isMention: this.isBotMentioned(raw, text),
});
return message;
}
private extractAttachments(raw: TelegramMessage): Attachment[] {
const attachments: Attachment[] = [];
const photo = raw.photo?.at(-1);
if (photo) {
attachments.push(
this.createAttachment("image", photo.file_id, {
size: photo.file_size,
width: photo.width,
height: photo.height,
})
);
}
if (raw.video) {
attachments.push(
this.createAttachment("video", raw.video.file_id, {
size: raw.video.file_size,
width: raw.video.width,
height: raw.video.height,
name: raw.video.file_name,
mimeType: raw.video.mime_type,
})
);
}
if (raw.audio) {
attachments.push(
this.createAttachment("audio", raw.audio.file_id, {
size: raw.audio.file_size,
name: raw.audio.file_name,
mimeType: raw.audio.mime_type,
})
);
}
if (raw.voice) {
attachments.push(
this.createAttachment("audio", raw.voice.file_id, {
size: raw.voice.file_size,
mimeType: raw.voice.mime_type,
})
);
}
if (raw.document) {
attachments.push(
this.createAttachment("file", raw.document.file_id, {
size: raw.document.file_size,
name: raw.document.file_name,
mimeType: raw.document.mime_type,
})
);
}
return attachments;
}
private createAttachment(
type: Attachment["type"],
fileId: string,
metadata?: {
size?: number;
width?: number;
height?: number;
name?: string;
mimeType?: string;
}
): Attachment {
return {
type,
size: metadata?.size,
width: metadata?.width,
height: metadata?.height,
name: metadata?.name,
mimeType: metadata?.mimeType,
fetchData: async () => this.downloadFile(fileId),
};
}
private async downloadFile(fileId: string): Promise<Buffer> {
const file = await this.telegramFetch<TelegramFile>("getFile", {
file_id: fileId,
});
if (!file.file_path) {
throw new ResourceNotFoundError("telegram", "file", fileId);
}
const fileUrl = `${this.apiBaseUrl}/file/bot${this.botToken}/${file.file_path}`;
let response: Response;
try {
response = await fetch(fileUrl);
} catch (error) {
throw new NetworkError(
"telegram",
`Failed to download Telegram file ${fileId}`,
error instanceof Error ? error : undefined
);
}
if (!response.ok) {
throw new NetworkError(
"telegram",
`Failed to download Telegram file ${fileId}: ${response.status}`
);
}
return Buffer.from(await response.arrayBuffer());
}
private async sendDocument(
thread: TelegramThreadId,
file: {
filename: string;
data: Buffer | Blob | ArrayBuffer;
mimeType?: string;
},
text: string,
replyMarkup?: TelegramInlineKeyboardMarkup,
parseMode?: string
): Promise<TelegramMessage> {
const buffer = await this.toTelegramBuffer(file.data);
const formData = new FormData();
formData.append("chat_id", thread.chatId);
if (typeof thread.messageThreadId === "number") {
formData.append("message_thread_id", String(thread.messageThreadId));
}
if (text.trim()) {
formData.append("caption", this.truncateCaption(text));
if (parseMode) {
formData.append("parse_mode", parseMode);
}
}
const blob = new Blob([new Uint8Array(buffer)], {
type: file.mimeType ?? "application/octet-stream",
});
formData.append("document", blob, file.filename);
if (replyMarkup) {
formData.append("reply_markup", JSON.stringify(replyMarkup));
}
return this.telegramFetch<TelegramMessage>("sendDocument", formData);
}
private async toTelegramBuffer(
data: Buffer | Blob | ArrayBuffer
): Promise<Buffer> {
if (Buffer.isBuffer(data)) {
return data;
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data);
}
if (data instanceof Blob) {
return Buffer.from(await data.arrayBuffer());
}
throw new ValidationError("telegram", "Unsupported file data type");
}
private paginateMessages(
messages: Message<TelegramRawMessage>[],
options: FetchOptions
): FetchResult<TelegramRawMessage> {
const limit = Math.max(1, Math.min(options.limit ?? 50, 100));
const direction = options.direction ?? "backward";
if (messages.length === 0) {
return { messages: [] };
}
const messageIndexById = new Map(
messages.map((message, index) => [message.id, index])
);
if (direction === "backward") {
const end =
options.cursor && messageIndexById.has(options.cursor)
? (messageIndexById.get(options.cursor) ?? messages.length)
: messages.length;
const start = Math.max(0, end - limit);
const page = messages.slice(start, end);
return {
messages: page,
nextCursor: start > 0 ? page[0]?.id : undefined,
};
}
const start =
options.cursor && messageIndexById.has(options.cursor)
? (messageIndexById.get(options.cursor) ?? -1) + 1
: 0;
const end = Math.min(messages.length, start + limit);
const page = messages.slice(start, end);
return {
messages: page,
nextCursor: end < messages.length ? page.at(-1)?.id : undefined,
};
}
private cacheMessage(message: Message<TelegramRawMessage>): void {
const existing = this.messageCache.get(message.threadId) ?? [];
const index = existing.findIndex((item) => item.id === message.id);
if (index >= 0) {
existing[index] = message;
} else {
existing.push(message);
}
existing.sort((a, b) => this.compareMessages(a, b));
this.messageCache.set(message.threadId, existing);
}
private findCachedMessage(
messageId: string
): Message<TelegramRawMessage> | undefined {
for (const messages of this.messageCache.values()) {
const found = messages.find((message) => message.id === messageId);
if (found) {
return found;
}
}
return undefined;
}
private deleteCachedMessage(messageId: string): void {
for (const [threadId, messages] of this.messageCache.entries()) {
const filtered = messages.filter((message) => message.id !== messageId);
if (filtered.length === 0) {
this.messageCache.delete(threadId);
} else if (filtered.length !== messages.length) {
this.messageCache.set(threadId, filtered);
}
}
}
private compareMessages(
a: Message<TelegramRawMessage>,
b: Message<TelegramRawMessage>
): number {
const timeDiff =
a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime();
if (timeDiff !== 0) {
return timeDiff;
}
return this.messageSequence(a.id) - this.messageSequence(b.id);
}
private messageSequence(messageId: string): number {
const match = messageId.match(MESSAGE_SEQUENCE_PATTERN);
return match ? Number.parseInt(match[1], 10) : 0;
}
private resolveThreadId(value: string): TelegramThreadId {
if (value.startsWith("telegram:")) {
return this.decodeThreadId(value);
}
return { chatId: value };
}
private encodeMessageId(chatId: string, messageId: number): string {
return `${chatId}:${messageId}`;
}
private decodeCompositeMessageId(
messageId: string,
expectedChatId?: string
): { chatId: string; messageId: number; compositeId: string } {
const compositeMatch = messageId.match(MESSAGE_ID_PATTERN);
if (compositeMatch) {
const [, chatId, rawMessageId] = compositeMatch;
const parsedMessageId = Number.parseInt(rawMessageId, 10);
if (expectedChatId && chatId !== expectedChatId) {
throw new ValidationError(
"telegram",
`Message ID chat mismatch: expected ${expectedChatId}, got ${chatId}`
);
}
return {
chatId,
messageId: parsedMessageId,
compositeId: `${chatId}:${parsedMessageId}`,
};
}
if (!expectedChatId) {
throw new ValidationError(
"telegram",
`Telegram message ID must be in <chatId>:<messageId> format, got: ${messageId}`
);
}
const parsedMessageId = Number.parseInt(messageId, 10);
if (!Number.isFinite(parsedMessageId)) {
throw new ValidationError(
"telegram",
`Invalid Telegram message ID: ${messageId}`
);
}
return {
chatId: expectedChatId,
messageId: parsedMessageId,
compositeId: `${expectedChatId}:${parsedMessageId}`,
};
}
private toAuthor(user: TelegramUser): TelegramMessageAuthor {
const fullName = [user.first_name, user.last_name]
.filter(Boolean)
.join(" ")
.trim();
return {
userId: String(user.id),
userName: user.username ?? user.first_name ?? String(user.id),
fullName: fullName || user.username || String(user.id),
isBot: user.is_bot,
isMe: String(user.id) === this._botUserId,
};
}
private toReactionActorAuthor(chat: TelegramChat): TelegramMessageAuthor {
const name = this.chatDisplayName(chat) ?? String(chat.id);
return {
userId: `chat:${chat.id}`,
userName: name,
fullName: name,
isBot: "unknown" as const,
isMe: false,
};
}
private chatDisplayName(chat: TelegramChat): string | undefined {
if (chat.title) {
return chat.title;
}
const privateName = [chat.first_name, chat.last_name]
.filter(Boolean)
.join(" ")
.trim();
if (privateName) {
return privateName;
}
return chat.username;
}
private isBotMentioned(message: TelegramMessage, text: string): boolean {
if (!text) {
return false;
}
const username = this._userName;
const entities = message.entities ?? message.caption_entities ?? [];
for (const entity of entities) {
if (entity.type === "mention") {
const mentionText = this.entityText(text, entity);
if (mentionText.toLowerCase() === `@${username.toLowerCase()}`) {
return true;
}
}
if (
entity.type === "text_mention" &&
entity.user &&
this._botUserId &&
String(entity.user.id) === this._botUserId
) {
return true;
}
if (entity.type === "bot_command") {
const commandText = this.entityText(text, entity);
if (commandText.toLowerCase().endsWith(`@${username.toLowerCase()}`)) {
return true;
}
}
}
const mentionRegex = new RegExp(`@${this.escapeRegex(username)}\\b`, "i");
return mentionRegex.test(text);
}
private entityText(text: string, entity: TelegramMessageEntity): string {
return text.slice(entity.offset, entity.offset + entity.length);
}
private escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
private normalizeUserName(value: string): string {
return value.replace(LEADING_AT_PATTERN, "").trim() || "bot";
}
private truncateMessage(text: string): string {
if (text.length <= TELEGRAM_MESSAGE_LIMIT) {
return text;
}
return `${text.slice(0, TELEGRAM_MESSAGE_LIMIT - 3)}...`;
}
private truncateCaption(text: string): string {
if (text.length <= TELEGRAM_CAPTION_LIMIT) {
return text;
}
return `${text.slice(0, TELEGRAM_CAPTION_LIMIT - 3)}...`;
}
private toTelegramReaction(emoji: EmojiValue | string): TelegramReactionType {
if (typeof emoji !== "string") {
return {
type: "emoji",
emoji: defaultEmojiResolver.toGChat(emoji.name),
};
}
if (emoji.startsWith("custom:")) {
return {
type: "custom_emoji",
custom_emoji_id: emoji.slice("custom:".length),
};
}
const placeholderMatch = emoji.match(EMOJI_PLACEHOLDER_PATTERN);
if (placeholderMatch) {
return {
type: "emoji",
emoji: defaultEmojiResolver.toGChat(placeholderMatch[1]),
};
}
if (EMOJI_NAME_PATTERN.test(emoji)) {
return {
type: "emoji",
emoji: defaultEmojiResolver.toGChat(emoji.toLowerCase()),
};
}
return {
type: "emoji",
emoji,
};
}
private reactionKey(reaction: TelegramReactionType): string {
if (reaction.type === "emoji") {
return reaction.emoji;
}
return `custom:${reaction.custom_emoji_id}`;
}
private reactionToEmojiValue(reaction: TelegramReactionType): EmojiValue {
if (reaction.type === "emoji") {
return defaultEmojiResolver.fromGChat(reaction.emoji);
}
return getEmoji(`custom:${reaction.custom_emoji_id}`);
}
private async telegramFetch<TResult>(
method: string,
payload?: Record<string, unknown> | FormData
): Promise<TResult> {
const url = `${this.apiBaseUrl}/bot${this.botToken}/${method}`;
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers:
payload instanceof FormData
? undefined
: {
"Content-Type": "application/json",
},
body:
payload instanceof FormData ? payload : JSON.stringify(payload ?? {}),
});
} catch (error) {
throw new NetworkError(
"telegram",
`Network error calling Telegram ${method}`,
error instanceof Error ? error : undefined
);
}
let data: TelegramApiResponse<TResult>;
try {
data = (await response.json()) as TelegramApiResponse<TResult>;
} catch {
throw new NetworkError(
"telegram",
`Failed to parse Telegram API response for ${method}`
);
}
if (!(response.ok && data.ok)) {
this.throwTelegramApiError(method, response.status, data);
}
if (typeof data.result === "undefined") {
throw new NetworkError(
"telegram",
`Telegram API ${method} returned no result`
);
}
return data.result;
}
private throwTelegramApiError(
method: string,
status: number,
data: TelegramApiResponse<unknown>
): never {
const errorCode = data.error_code ?? status;
const description = data.description ?? `Telegram API ${method} failed`;
if (errorCode === 429) {
throw new AdapterRateLimitError("telegram", data.parameters?.retry_after);
}
if (errorCode === 401) {
throw new AuthenticationError("telegram", description);
}
if (errorCode === 403) {
throw new PermissionError("telegram", method);
}
if (errorCode === 404) {
throw new ResourceNotFoundError("telegram", method);
}
if (errorCode >= 400 && errorCode < 500) {
throw new ValidationError("telegram", description);
}
throw new NetworkError(
"telegram",
`${description} (status ${status}, error ${errorCode})`
);
}
}
export function createTelegramAdapter(
config?: Partial<
TelegramAdapterConfig & { logger: Logger; userName?: string }
>
): TelegramAdapter {
const botToken = config?.botToken ?? process.env.TELEGRAM_BOT_TOKEN;
if (!botToken) {
throw new ValidationError(
"telegram",
"botToken is required. Set TELEGRAM_BOT_TOKEN or provide it in config."
);
}
const apiBaseUrl =
config?.apiBaseUrl ??
process.env.TELEGRAM_API_BASE_URL ??
TELEGRAM_API_BASE;
const secretToken =
config?.secretToken ?? process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN;
const userName = config?.userName ?? process.env.TELEGRAM_BOT_USERNAME;
return new TelegramAdapter({
botToken,
apiBaseUrl,
secretToken,
logger: config?.logger ?? new ConsoleLogger("info").child("telegram"),
userName,
});
}
export { TelegramFormatConverter } from "./markdown";
export type {
TelegramAdapterConfig,
TelegramCallbackQuery,
TelegramChat,
TelegramMessage,
TelegramMessageReactionUpdated,
TelegramRawMessage,
TelegramThreadId,
TelegramUpdate,
TelegramUser,
} from "./types";