| import type { BotAttachmentKind, BotChannelSettings } from '@maka/core'; |
| import { generalizedErrorMessage } from '@maka/core/redaction'; |
| import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js'; |
| import type { BotPlatform, BotSendOptions, BotStatus, SendCapable } from './types.js'; |
| import { proxiedFetch } from './proxied-fetch.js'; |
| |
| const TELEGRAM_POLL_TIMEOUT_S = 15; |
| const TELEGRAM_REQUEST_TIMEOUT_MS = 10_000; |
| const FEISHU_REQUEST_TIMEOUT_MS = 10_000; |
| |
| /** |
| * PR-TELEGRAM-UTF16-LIMIT-0 (external bot research #B3): Telegram's |
| * 4096-character message cap is measured in UTF-16 code units, NOT |
| * Python-style codepoints. Astral-plane characters (most emoji, CJK |
| * Extension B, music symbols) consume 2 code units each. Without |
| * this guard, an emoji-heavy 2049-codepoint message overflows the |
| * 4096 limit and Telegram returns 400. |
| * |
| * Limit pulled DOWN to 4000 so a "[1/N]" continuation marker fits |
| * inside the cap on the producer side without re-measuring. |
| */ |
| const TELEGRAM_MAX_UTF16_PER_MESSAGE = 4000; |
| |
| /** Count UTF-16 code units in `s` (surrogate pairs count as 2). */ |
| function utf16Len(s: string): number { |
| return s.length === 0 ? 0 : Buffer.byteLength(s, 'utf16le') / 2; |
| } |
| |
| /** |
| * Return the longest prefix of `s` whose UTF-16 length is ≤ `cap`, |
| * respecting surrogate-pair boundaries (we never slice a |
| * multi-code-unit character in half). We iterate codepoint-by- |
| * codepoint instead of binary-searching slices: the cost of |
| * mistakenly splitting an emoji is far worse than the O(n) cost. |
| */ |
| function prefixWithinUtf16(s: string, cap: number): string { |
| if (utf16Len(s) <= cap) return s; |
| let used = 0; |
| let end = 0; |
| for (let i = 0; i < s.length; ) { |
| const code = s.codePointAt(i)!; |
| const units = code > 0xffff ? 2 : 1; |
| if (used + units > cap) break; |
| used += units; |
| i += units; |
| end = i; |
| } |
| return s.slice(0, end); |
| } |
| |
| /** |
| * Split `text` into UTF-16-bounded chunks for Telegram delivery. |
| * Prefers breaking on a newline within the last ~10% of the chunk; |
| * falls back to a hard prefix cut when the chunk has no newline. |
| * |
| * The chunk count is emitted as a `[i/N]` header on the first |
| * line of each piece so the receiver knows the message is split. |
| */ |
| function splitForTelegram(text: string): string[] { |
| if (utf16Len(text) <= TELEGRAM_MAX_UTF16_PER_MESSAGE) return [text]; |
| const HEADER_RESERVE = 12; // room for "[99/99]\n" |
| const cap = TELEGRAM_MAX_UTF16_PER_MESSAGE - HEADER_RESERVE; |
| const pieces: string[] = []; |
| let remaining = text; |
| while (utf16Len(remaining) > cap) { |
| let chunk = prefixWithinUtf16(remaining, cap); |
| const minBoundary = Math.floor(chunk.length * 0.9); |
| const nl = chunk.lastIndexOf('\n'); |
| if (nl >= minBoundary) chunk = chunk.slice(0, nl); |
| pieces.push(chunk); |
| remaining = remaining.slice(chunk.length).replace(/^\n/, ''); |
| } |
| if (remaining.length > 0) pieces.push(remaining); |
| const total = pieces.length; |
| return pieces.map((piece, idx) => `[${idx + 1}/${total}]\n${piece}`); |
| } |
| |
| /** |
| * PR-BOT-REPLY-TO-MESSAGE-0: build the Telegram `sendMessage` body for |
| * one chunk. `chunkIndex === 0` is the first chunk of a split send and |
| * is the only piece that threads under the originating user message. |
| * Continuation chunks render as ordinary sequential messages. |
| * |
| * `allow_sending_without_reply: true` lets Telegram still deliver if |
| * the parent message was deleted — preserving Maka's response rather |
| * than rejecting it with 400. |
| */ |
| function buildTelegramSendBody( |
| chatId: string, |
| chunk: string, |
| options: BotSendOptions | undefined, |
| chunkIndex: number, |
| ): Record<string, unknown> { |
| const body: Record<string, unknown> = { |
| chat_id: chatId, |
| text: chunk, |
| }; |
| const replyToMessageId = normalizeTelegramReplyToMessageId(options?.replyToMessageId); |
| if (chunkIndex === 0 && replyToMessageId !== undefined) { |
| body.reply_to_message_id = replyToMessageId; |
| body.allow_sending_without_reply = true; |
| } |
| return body; |
| } |
| |
| function normalizeTelegramReplyToMessageId(value: string | undefined): number | undefined { |
| if (value === undefined) return undefined; |
| const trimmed = value.trim(); |
| if (!/^[1-9]\d*$/.test(trimmed)) return undefined; |
| const numeric = Number(trimmed); |
| return Number.isSafeInteger(numeric) ? numeric : undefined; |
| } |
| |
| /** |
| * PR-BOT-USER-ALLOWLIST-0: runtime allowlist gate. `undefined` or empty |
| * means no restriction; any other set is enforced as exact match against |
| * the platform-native user id. The settings normalize layer already |
| * trims, dedups, and caps the persisted array, so this function only |
| * has to do membership. |
| */ |
| function isAllowedUser(allowedUserIds: ReadonlyArray<string> | undefined, userId: string): boolean { |
| if (!allowedUserIds || allowedUserIds.length === 0) return true; |
| return allowedUserIds.includes(userId); |
| } |
| |
| /** |
| * PR-BOT-NON-TEXT-MESSAGE-ACK-0 (external bot research): map a Telegram |
| * `message` object to a stable {@link BotAttachmentKind} so the handler |
| * can choose between an ingest path (text-bearing) and an ack-only |
| * path (non-text payload that Maka cannot interpret yet). |
| * |
| * Order matters: photo / voice / video are the most common and we want |
| * the most accurate label for them. `sticker` and `animation` overlap |
| * with images/videos at the Telegram protocol level — they get their |
| * own ack copy so the user is not told "send a photo's question" when |
| * what they sent was a sticker. |
| */ |
| function telegramAttachmentKind(message: any): BotAttachmentKind | undefined { |
| if (!message || typeof message !== 'object') return undefined; |
| if (Array.isArray(message.photo) && message.photo.length > 0) return 'photo'; |
| if (message.voice) return 'voice'; |
| if (message.audio) return 'audio'; |
| if (message.sticker) return 'sticker'; |
| if (message.animation) return 'animation'; |
| if (message.video || message.video_note) return 'video'; |
| if (message.document) return 'document'; |
| if (message.location || message.contact || message.poll || message.dice || message.venue) |
| return 'unknown'; |
| return undefined; |
| } |
| |
| /** |
| * PR-BOT-EPHEMERAL-REPLY-0 (external bot research): decide whether the |
| * caller asked for ephemeral cleanup, and if so how long to wait. |
| * Returns `undefined` when no cleanup should be scheduled. Telegram |
| * silently refuses bot self-delete past 48 hours in DMs, so clamping |
| * here prevents scheduling a timer that has no chance of succeeding. |
| * The lower bound of 1s defends against an immediate-self-delete that |
| * would race the send completing on the receiver. |
| */ |
| const EPHEMERAL_REPLY_MIN_MS = 1_000; |
| const EPHEMERAL_REPLY_MAX_MS = 48 * 60 * 60 * 1_000; |
| |
| function ephemeralDelayFromOptions(options: BotSendOptions | undefined): number | undefined { |
| if (!options || typeof options.ephemeralTtlMs !== 'number') return undefined; |
| if (!Number.isFinite(options.ephemeralTtlMs) || options.ephemeralTtlMs <= 0) return undefined; |
| return Math.min(Math.max(options.ephemeralTtlMs, EPHEMERAL_REPLY_MIN_MS), EPHEMERAL_REPLY_MAX_MS); |
| } |
| |
| /** |
| * PR-BOT-RATELIMIT-RETRY-0 (external bot research): classify a Telegram |
| * sendMessage response so the caller can decide between "done", "retry |
| * after Telegram's stated backoff", and "give up". Pure on the |
| * response object so the decision can be unit-tested without mocking |
| * the network. |
| * |
| * Why 429 specifically: Telegram's per-chat rate limits trigger 429s |
| * with a `parameters.retry_after` integer (seconds). A short, bounded |
| * retry handles the burst case (e.g. agent finished and emits 5 chunks |
| * back-to-back) without silently dropping the message. Other failure |
| * codes (400 bad request, 401 unauthorized, 403 forbidden, 5xx) are |
| * NOT retried — 4xx is permanent and 5xx Telegram outages do not |
| * resolve in a 30s window. |
| * |
| * Bounds: |
| * - Retry delay is clamped to [1000ms, 30_000ms]. Telegram has been |
| * observed to return inflated retry_after values during incidents; |
| * a 30s cap keeps the bridge responsive. |
| * - Exactly one retry. We do not loop — if the second attempt also |
| * returns 429 the caller marks degraded and returns. Repeated 429 |
| * under load is a deployment / rate-policy issue, not something an |
| * unbounded retry loop should mask. |
| */ |
| export type TelegramSendClassification = |
| | { kind: 'ok'; messageId: string | null } |
| | { kind: 'retry'; delayMs: number } |
| | { kind: 'fatal'; description: string }; |
| |
| const TELEGRAM_RETRY_MIN_MS = 1_000; |
| const TELEGRAM_RETRY_MAX_MS = 30_000; |
| |
| function classifyTelegramSendResponse(response: any): TelegramSendClassification { |
| if (response && response.ok === true) { |
| const id = response.result?.message_id; |
| return { |
| kind: 'ok', |
| messageId: typeof id === 'number' || typeof id === 'string' ? String(id) : null, |
| }; |
| } |
| if (response && response.error_code === 429) { |
| const raw = Number(response.parameters?.retry_after ?? 0); |
| const requested = Number.isFinite(raw) && raw > 0 ? raw * 1000 : TELEGRAM_RETRY_MIN_MS; |
| const delayMs = Math.min(Math.max(requested, TELEGRAM_RETRY_MIN_MS), TELEGRAM_RETRY_MAX_MS); |
| return { kind: 'retry', delayMs }; |
| } |
| const description = |
| typeof response?.description === 'string' ? response.description : 'send-failed'; |
| return { kind: 'fatal', description }; |
| } |
| |
| export const __TEST__ = { |
| utf16Len, |
| prefixWithinUtf16, |
| splitForTelegram, |
| buildTelegramSendBody, |
| normalizeTelegramReplyToMessageId, |
| isAllowedUser, |
| classifyTelegramSendResponse, |
| TELEGRAM_RETRY_MIN_MS, |
| TELEGRAM_RETRY_MAX_MS, |
| ephemeralDelayFromOptions, |
| EPHEMERAL_REPLY_MIN_MS, |
| EPHEMERAL_REPLY_MAX_MS, |
| telegramAttachmentKind, |
| }; |
| |
| export class SimpleBotBridge extends BaseBotAdapter implements SendCapable { |
| private abortController: AbortController | null = null; |
| private offset = 0; |
| |
| constructor(platform: BotPlatform, settings: BotChannelSettings) { |
| super(platform, settings); |
| } |
| |
| async start(): Promise<void> { |
| if (this.running) return; |
| if (!this.settings.enabled) { |
| this.reason = 'disabled'; |
| this.readiness = 'scaffolded'; |
| return; |
| } |
| if (!this.settings.token.trim()) { |
| this.reason = 'no-token'; |
| this.readiness = 'scaffolded'; |
| return; |
| } |
| |
| if (this.platform === 'telegram') { |
| await this.startTelegram(); |
| return; |
| } |
| |
| if (this.platform === 'feishu') { |
| await this.startFeishu(); |
| return; |
| } |
| |
| if (this.platform === 'discord') { |
| this.running = false; |
| this.reason = 'scaffold-only'; |
| this.readiness = 'configured'; |
| this.emitStatusChange(); |
| return; |
| } |
| |
| this.reason = 'unimplemented'; |
| this.readiness = 'scaffolded'; |
| this.emitStatusChange(); |
| } |
| |
| async stop(): Promise<void> { |
| this.running = false; |
| this.abortController?.abort(); |
| this.abortController = null; |
| this.reason = 'stopped'; |
| this.readiness = botReadinessFromSettings(this.settings); |
| this.emitStatusChange(); |
| } |
| |
| async sendMessage( |
| chatId: string, |
| text: string, |
| options?: BotSendOptions, |
| ): Promise<string | null> { |
| if (this.platform !== 'telegram' || !this.running) return null; |
| // PR-TELEGRAM-UTF16-LIMIT-0: split first if the message would |
| // exceed Telegram's 4096 UTF-16 code unit cap. The split helper |
| // returns the original text untouched when it already fits, so |
| // the common short-message path stays a single API call. |
| const chunks = splitForTelegram(text); |
| let lastMessageId: string | null = null; |
| for (let i = 0; i < chunks.length; i++) { |
| const chunk = chunks[i]; |
| const body = buildTelegramSendBody(chatId, chunk, options, i); |
| // PR-BOT-RATELIMIT-RETRY-0: one bounded retry on Telegram 429. |
| // Burst-send (agent emits 5 chunks back-to-back) is exactly the |
| // case this targets — without the retry, chunk 2 silently drops |
| // and the user sees a truncated reply. |
| let response = await telegramApi(this.settings.token, 'sendMessage', body); |
| let classification = classifyTelegramSendResponse(response); |
| if (classification.kind === 'retry') { |
| await sleep(classification.delayMs); |
| response = await telegramApi(this.settings.token, 'sendMessage', body); |
| classification = classifyTelegramSendResponse(response); |
| } |
| if (classification.kind !== 'ok') { |
| this.readiness = this.readiness === 'operational' ? 'degraded' : 'credentials_valid'; |
| this.reason = classification.kind === 'retry' ? 'rate-limited' : classification.description; |
| this.emitStatusChange(); |
| return null; |
| } |
| lastMessageId = classification.messageId ?? lastMessageId; |
| } |
| this.readiness = 'operational'; |
| this.reason = undefined; |
| this.lastEventAt = Date.now(); |
| this.emitStatusChange(); |
| // PR-BOT-EPHEMERAL-REPLY-0: schedule a self-delete of the FIRST |
| // message we sent (system notice TTL). Multi-chunk sends keep |
| // their tail visible — only the head is treated as the "system |
| // notice" worth garbage-collecting. We attach this AFTER the |
| // status emit so a failure here cannot regress the successful-send |
| // contract observed by listeners. |
| const ephemeralDelay = ephemeralDelayFromOptions(options); |
| if (ephemeralDelay !== undefined && lastMessageId) { |
| const token = this.settings.token; |
| const targetMessageId = lastMessageId; |
| const targetChatId = chatId; |
| setTimeout(() => { |
| void telegramApi(token, 'deleteMessage', { |
| chat_id: targetChatId, |
| message_id: Number(targetMessageId), |
| }).catch(() => undefined); |
| }, ephemeralDelay).unref?.(); |
| } |
| return lastMessageId; |
| } |
| |
| /** |
| * PR-BOT-TYPING-INDICATOR-0: post Telegram's `sendChatAction` so the |
| * "Maka is typing…" affordance shows in the client while the agent |
| * generates its reply. Failure is swallowed — typing is decorative |
| * and must never block / corrupt the actual reply path. |
| */ |
| async sendTypingIndicator(chatId: string): Promise<boolean> { |
| if (this.platform !== 'telegram' || !this.running) return false; |
| try { |
| const response = await telegramApi(this.settings.token, 'sendChatAction', { |
| chat_id: chatId, |
| action: 'typing', |
| }); |
| return response?.ok === true; |
| } catch { |
| return false; |
| } |
| } |
| |
| private async startTelegram(): Promise<void> { |
| try { |
| const me = await telegramApi(this.settings.token, 'getMe'); |
| if (!me.ok) { |
| this.reason = me.description ?? 'get-me-failed'; |
| this.readiness = 'configured'; |
| this.emitStatusChange(); |
| return; |
| } |
| this.identity = { |
| id: String(me.result?.id ?? ''), |
| username: me.result?.username, |
| displayName: me.result?.first_name, |
| }; |
| this.running = true; |
| this.startedAt = Date.now(); |
| this.reason = undefined; |
| // getMe proves credentials and API reachability. It is not a |
| // send/receive smoke, so it must not be surfaced as operational. |
| this.readiness = 'credentials_valid'; |
| this.emitStatusChange(); |
| void this.pollTelegram(); |
| } catch (error) { |
| this.reason = generalizedErrorMessage(error); |
| this.readiness = |
| this.readiness === 'operational' ? 'degraded' : botReadinessFromSettings(this.settings); |
| this.emitStatusChange(); |
| } |
| } |
| |
| private async startFeishu(): Promise<void> { |
| try { |
| const appId = this.settings.appId?.trim() ?? ''; |
| const appSecret = this.settings.appSecret?.trim() || this.settings.token.trim(); |
| if (!appId || !appSecret) { |
| this.running = false; |
| this.reason = 'missing-feishu-credentials'; |
| this.readiness = 'scaffolded'; |
| this.emitStatusChange(); |
| return; |
| } |
| const token = await feishuTenantAccessToken(appId, appSecret); |
| if (!token.ok) { |
| this.running = false; |
| this.reason = token.error; |
| this.readiness = 'configured'; |
| this.emitStatusChange(); |
| return; |
| } |
| this.identity = { |
| id: appId, |
| username: appId, |
| displayName: appId, |
| }; |
| this.running = false; |
| this.startedAt = Date.now(); |
| this.reason = this.settings.domain?.trim() |
| ? 'feishu-events-not-connected' |
| : 'feishu-domain-required'; |
| // tenant_access_token proves app credentials. Feishu event delivery still |
| // needs a callback/long-connection runtime before it can be operational. |
| this.readiness = 'credentials_valid'; |
| this.emitStatusChange(); |
| } catch (error) { |
| this.running = false; |
| this.reason = generalizedErrorMessage(error); |
| this.readiness = |
| this.readiness === 'operational' ? 'degraded' : botReadinessFromSettings(this.settings); |
| this.emitStatusChange(); |
| } |
| } |
| |
| private async pollTelegram(): Promise<void> { |
| while (this.running) { |
| this.abortController = new AbortController(); |
| try { |
| const updates = await telegramApi( |
| this.settings.token, |
| 'getUpdates', |
| { |
| offset: this.offset, |
| timeout: TELEGRAM_POLL_TIMEOUT_S, |
| allowed_updates: ['message'], |
| }, |
| this.abortController.signal, |
| ); |
| if (!updates.ok || !Array.isArray(updates.result)) { |
| await sleep(5_000); |
| continue; |
| } |
| for (const update of updates.result) { |
| this.offset = Number(update.update_id ?? this.offset) + 1; |
| this.handleTelegramMessage(update.message); |
| } |
| } catch (error) { |
| if (!this.running) return; |
| if (error instanceof Error && error.name === 'AbortError') return; |
| await sleep(5_000); |
| } |
| } |
| } |
| |
| private handleTelegramMessage(message: any): void { |
| if (!message?.from) return; |
| const userId = String(message.from.id); |
| // PR-BOT-USER-ALLOWLIST-0: drop unauthorized senders silently when an |
| // allowlist is configured. No bounce reply — that would let scanners |
| // enumerate the policy by toggling IDs. Status fields are NOT updated |
| // for dropped messages so the bridge's `lastEventAt` continues to |
| // reflect authentic activity from authorized users only. |
| if (!isAllowedUser(this.settings.allowedUserIds, userId)) return; |
| this.lastEventAt = Date.now(); |
| this.readiness = 'operational'; |
| this.reason = undefined; |
| const attachmentKind = telegramAttachmentKind(message); |
| this.emitIncomingMessage({ |
| platform: 'telegram', |
| userId, |
| userName: message.from.username ?? message.from.first_name ?? userId, |
| chatId: String(message.chat?.id ?? ''), |
| isGroup: message.chat?.type === 'group' || message.chat?.type === 'supergroup', |
| text: message.text ?? message.caption ?? '', |
| sourceMessageId: String(message.message_id ?? ''), |
| receivedAt: this.lastEventAt, |
| ...(attachmentKind ? { attachmentKind } : {}), |
| }); |
| this.emitStatusChange(); |
| } |
| |
| protected override connectionKind(): BotStatus['connection'] { |
| if (this.platform === 'telegram') return 'polling'; |
| if (this.platform === 'discord' || this.platform === 'feishu') return 'gateway'; |
| return 'none'; |
| } |
| } |
| |
| async function telegramApi( |
| token: string, |
| method: string, |
| body?: Record<string, unknown>, |
| signal?: AbortSignal, |
| ): Promise<any> { |
| const timeoutMs = |
| typeof body?.timeout === 'number' ? (body.timeout + 5) * 1_000 : TELEGRAM_REQUEST_TIMEOUT_MS; |
| const response = await proxiedFetch(`https://api.telegram.org/bot${token}/${method}`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: body ? JSON.stringify(body) : undefined, |
| signal, |
| timeoutMs, |
| }); |
| return response.json(); |
| } |
| |
| async function feishuTenantAccessToken( |
| appId: string, |
| appSecret: string, |
| ): Promise<{ ok: true; token: string } | { ok: false; error: string }> { |
| const response = await proxiedFetch( |
| 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', |
| { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ app_id: appId, app_secret: appSecret }), |
| timeoutMs: FEISHU_REQUEST_TIMEOUT_MS, |
| }, |
| ); |
| const json = await response.json(); |
| if (json.code !== 0 || !json.tenant_access_token) { |
| return { ok: false, error: json.msg ?? 'Failed to issue tenant_access_token' }; |
| } |
| return { ok: true, token: json.tenant_access_token }; |
| } |
| |
| function sleep(ms: number): Promise<void> { |
| return new Promise((resolve) => setTimeout(resolve, ms)); |
| } |