blob: e3437d62424ed543215d330f47ee6daff16dd900 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Telegram bot bridge — HTTP long-poll (`getUpdates`) receive loop,
* REST send with UTF-16 chunk splitting, reply threading, typing
* indicator, ephemeral self-delete, and native draft streaming.
* Formerly `simple-bridge.ts`; the registry is the only constructor
* and only ever passes `platform === 'telegram'`.
*/
import type { BotAttachmentKind } from '@maka/core/bot-events';
import type { BotChannelSettings } from '@maka/core/bot-chat-settings';
import { truncateUtf16Safe } from '@maka/core/text-sanitize';
import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js';
import type {
BotPlatform,
BotReplyStream,
BotReplyStreamOptions,
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;
/**
* 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;
const TELEGRAM_DRAFT_UPDATE_INTERVAL_MS = 250;
const TELEGRAM_DRAFT_KEEPALIVE_MS = 20_000;
const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
/** 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;
}
/**
* 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 = truncateUtf16Safe(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;
}
function normalizeTelegramPrivateChatId(value: string): number | 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,
splitForTelegram,
buildTelegramSendBody,
normalizeTelegramReplyToMessageId,
normalizeTelegramPrivateChatId,
isAllowedUser,
classifyTelegramSendResponse,
createTelegramReplyStream,
telegramDraftId,
};
export class TelegramBotBridge 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 = 'token_missing';
this.readiness = 'scaffolded';
return;
}
await this.startTelegram();
}
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.recordFailure(
classification.kind === 'retry' ? 'rate-limited' : classification.description,
classification.kind === 'retry' ? 'rate-limited' : 'send-failed',
);
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;
}
startReplyStream(chatId: string, options: BotReplyStreamOptions): BotReplyStream | null {
if (this.platform !== 'telegram' || !this.running || options.isGroup) return null;
const privateChatId = normalizeTelegramPrivateChatId(chatId);
if (privateChatId === undefined) return null;
const token = this.settings.token;
return createTelegramReplyStream({
chatId,
streamId: options.streamId,
prepareDraftText: (text) => truncateUtf16Safe(text, TELEGRAM_MAX_UTF16_PER_MESSAGE),
sendDraft: async (draftId, text) => {
const response = await telegramApi(token, 'sendMessageDraft', {
chat_id: privateChatId,
draft_id: draftId,
text,
});
return response?.ok === true;
},
sendFinal: (text) => this.sendMessage(chatId, text, options),
});
}
/**
* 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.recordFailure(
me.description ?? 'get-me-failed',
me.error_code === 401 ? 'token_invalid' : '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.recordFailure(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'] {
return 'polling';
}
}
interface TelegramReplyStreamDeps {
readonly chatId: string;
readonly streamId: string;
readonly prepareDraftText?: (text: string) => string;
readonly sendDraft: (draftId: number, text: string) => Promise<boolean>;
readonly sendFinal: (text: string) => Promise<string | null>;
readonly now?: () => number;
readonly setTimer?: (callback: () => void, delayMs: number) => NodeJS.Timeout | number;
readonly clearTimer?: (timer: NodeJS.Timeout | number) => void;
}
function createTelegramReplyStream(deps: TelegramReplyStreamDeps): BotReplyStream {
const now = deps.now ?? Date.now;
const setTimer = deps.setTimer ?? setTimeout;
const clearTimer = deps.clearTimer ?? clearTimeout;
const prepareDraftText = deps.prepareDraftText ?? ((text: string) => text);
const draftId = telegramDraftId(deps.chatId, deps.streamId);
let latestText = '';
let deliveredText: string | undefined;
let lastAttemptAt = Number.NEGATIVE_INFINITY;
let updateTimer: NodeJS.Timeout | number | undefined;
let keepaliveTimer: NodeJS.Timeout | number | undefined;
let closed = false;
let nativeDraftAvailable = true;
let inFlight: Promise<void> | undefined;
let finishTask: Promise<string | null> | undefined;
const cancelKeepalive = (): void => {
if (keepaliveTimer === undefined) return;
clearTimer(keepaliveTimer);
keepaliveTimer = undefined;
};
const scheduleKeepalive = (): void => {
if (
closed ||
!nativeDraftAvailable ||
deliveredText === undefined ||
keepaliveTimer !== undefined
)
return;
keepaliveTimer = setTimer(() => {
keepaliveTimer = undefined;
sendLatestDraft(true);
}, TELEGRAM_DRAFT_KEEPALIVE_MS);
if (typeof keepaliveTimer !== 'number') keepaliveTimer.unref?.();
};
const sendLatestDraft = (force = false): void => {
if (closed || !nativeDraftAvailable || inFlight || (!force && latestText === deliveredText)) {
return;
}
const text = latestText;
lastAttemptAt = now();
inFlight = (async () => {
try {
if (!(await deps.sendDraft(draftId, text))) {
nativeDraftAvailable = false;
return;
}
deliveredText = text;
} catch {
nativeDraftAvailable = false;
} finally {
inFlight = undefined;
if (latestText !== deliveredText) scheduleDraft();
else scheduleKeepalive();
}
})();
};
const scheduleDraft = (): void => {
if (
closed ||
!nativeDraftAvailable ||
inFlight ||
latestText === deliveredText ||
updateTimer !== undefined
) {
return;
}
const remaining = Math.max(0, TELEGRAM_DRAFT_UPDATE_INTERVAL_MS - (now() - lastAttemptAt));
if (remaining === 0) {
sendLatestDraft();
return;
}
updateTimer = setTimer(() => {
updateTimer = undefined;
sendLatestDraft();
}, remaining);
if (typeof updateTimer !== 'number') updateTimer.unref?.();
};
return {
update(text) {
const prepared = prepareDraftText(text);
if (closed || prepared === latestText) return;
latestText = prepared;
cancelKeepalive();
scheduleDraft();
},
async finish(finalText) {
if (finishTask) return finishTask;
if (!closed) {
closed = true;
if (updateTimer !== undefined) clearTimer(updateTimer);
updateTimer = undefined;
cancelKeepalive();
}
finishTask = (async () => {
await inFlight;
return deps.sendFinal(finalText);
})();
return finishTask;
},
async abort() {
if (finishTask) {
await finishTask.catch(() => undefined);
return;
}
if (closed) return;
closed = true;
if (updateTimer !== undefined) clearTimer(updateTimer);
updateTimer = undefined;
cancelKeepalive();
await inFlight;
},
};
}
function telegramDraftId(chatId: string, streamId: string): number {
let hash = 2_166_136_261;
for (const unit of `${chatId}\0${streamId}`) {
hash = Math.imul(hash ^ unit.charCodeAt(0), 16_777_619);
}
return ((hash >>> 0) % TELEGRAM_DRAFT_ID_MAX) + 1;
}
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();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}