Compare commits

...

2 Commits

Author SHA1 Message Date
rdmclin2 8d38d59e8e fix: add telegram timeout error 2026-05-08 16:36:21 +07:00
rdmclin2 41c71655b6 fix: bot message callback 2026-05-08 16:31:31 +07:00
6 changed files with 444 additions and 56 deletions
+44 -22
View File
@@ -250,15 +250,7 @@ export class BotCallbackService {
errorMessage,
);
const errorText = renderError(operationId, replyLocale);
try {
if (canEdit && progressMessageId) {
await messenger.editMessage(progressMessageId, errorText);
} else {
await messenger.createMessage(errorText);
}
} catch (error) {
log('handleCompletion: failed to send error message: %O', error);
}
await this.deliverFirstChunk(messenger, progressMessageId, errorText, canEdit);
return;
}
@@ -272,7 +264,10 @@ export class BotCallbackService {
return;
}
if (!lastAssistantContent) {
// `!lastAssistantContent` lets whitespace-only strings ("\n", " ") through;
// those collapse to empty text downstream and get rejected by Telegram as
// "message text is empty", silently losing the reply. Trim before testing.
if (!lastAssistantContent?.trim()) {
log('handleCompletion: no lastAssistantContent, skipping');
return;
}
@@ -291,20 +286,47 @@ export class BotCallbackService {
const finalText = client.formatReply?.(formattedBody, stats) ?? formattedBody;
const chunks = splitMessage(finalText, charLimit);
try {
if (canEdit && progressMessageId) {
await messenger.editMessage(progressMessageId, chunks[0]);
for (let i = 1; i < chunks.length; i++) {
await messenger.createMessage(chunks[i]);
}
} else {
// No progress message to edit or platform doesn't support edit — send all chunks as new messages
for (const chunk of chunks) {
await messenger.createMessage(chunk);
}
if (chunks.length === 0) {
log('handleCompletion: all chunks empty after formatting, skipping send');
return;
}
await this.deliverFirstChunk(messenger, progressMessageId, chunks[0], canEdit);
// Each remaining chunk gets its own try/catch so a single transient failure
// (rate-limit, network blip) doesn't drop everything that follows.
for (let i = 1; i < chunks.length; i++) {
try {
await messenger.createMessage(chunks[i]);
} catch (error) {
log('handleCompletion: failed to send chunk %d: %O', i, error);
}
}
}
/**
* Deliver the first chunk via edit when possible, else send a new message.
* If editing fails for any reason, fall back to createMessage so the agent's
* actual reply still reaches the user — silent edit failures were causing
* "agent ran but no reply appeared" reports on Telegram.
*/
private async deliverFirstChunk(
messenger: PlatformMessenger,
progressMessageId: string,
text: string,
canEdit: boolean,
): Promise<void> {
if (canEdit && progressMessageId) {
try {
await messenger.editMessage(progressMessageId, text);
return;
} catch (error) {
log('handleCompletion: editMessage failed, falling back to createMessage: %O', error);
}
}
try {
await messenger.createMessage(text);
} catch (error) {
log('handleCompletion: failed to send final message: %O', error);
log('handleCompletion: createMessage fallback failed: %O', error);
}
}
@@ -442,6 +442,79 @@ describe('BotCallbackService', () => {
await expect(service.handleCallback(body)).resolves.toBeUndefined();
});
it('should fall back to createMessage when editMessage fails on completion', async () => {
mockEditMessage.mockRejectedValueOnce(
new Error("Telegram API editMessageText failed: 400 Bad Request: can't parse entities"),
);
const body = makeBody({
lastAssistantContent: 'The actual answer the user needs.',
reason: 'completed',
type: 'completion',
});
await service.handleCallback(body);
expect(mockEditMessage).toHaveBeenCalledTimes(1);
// Reply must reach the user via createMessage fallback
expect(mockCreateMessage).toHaveBeenCalledWith(
expect.stringContaining('The actual answer the user needs.'),
);
});
it('should fall back to createMessage when error-state edit fails', async () => {
mockEditMessage.mockRejectedValueOnce(new Error('message to edit not found'));
const body = makeBody({
operationId: 'op-fallback-1',
reason: 'error',
type: 'completion',
});
await service.handleCallback(body);
expect(mockCreateMessage).toHaveBeenCalledWith(expect.stringContaining('op-fallback-1'));
});
it('should skip send when lastAssistantContent is whitespace-only', async () => {
const body = makeBody({
// Whitespace passes the original `!lastAssistantContent` check but
// collapses to empty downstream — Telegram would reject with
// "message text is empty" and silently drop the reply.
lastAssistantContent: ' \n\n ',
reason: 'completed',
type: 'completion',
});
await service.handleCallback(body);
expect(mockEditMessage).not.toHaveBeenCalled();
expect(mockCreateMessage).not.toHaveBeenCalled();
});
it('should still send subsequent chunks when one chunk fails mid-stream', async () => {
// Default 1800-char limit -> long content splits into multiple chunks.
const longContent = 'A'.repeat(2000) + '\n\n' + 'B'.repeat(2000) + '\n\n' + 'C'.repeat(2000);
// First follow-up chunk rejects; remaining chunks should still be attempted.
mockCreateMessage.mockRejectedValueOnce(
new Error('Telegram API sendMessage failed: 429 Too Many Requests'),
);
const body = makeBody({
lastAssistantContent: longContent,
reason: 'completed',
type: 'completion',
});
await service.handleCallback(body);
expect(mockEditMessage).toHaveBeenCalledTimes(1);
// The loop must keep going past the rejected chunk — at least 2 createMessage
// calls are expected (one rejected, one or more after it).
expect(mockCreateMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it('should not throw when sending interrupted message fails', async () => {
mockCreateMessage.mockRejectedValueOnce(new Error('Send failed'));
@@ -558,5 +558,21 @@ describe('replyTemplate', () => {
const text = 'chunk1\n\nchunk2\n\nchunk3';
expect(splitMessage(text, 10)).toEqual(['chunk1', 'chunk2', 'chunk3']);
});
it('should drop empty input rather than emitting a single empty chunk', () => {
// Telegram rejects empty/whitespace-only sendMessage as
// "message text is empty" — splitMessage must not produce one.
expect(splitMessage('', 100)).toEqual([]);
expect(splitMessage(' ', 100)).toEqual([]);
expect(splitMessage('\n\n\n', 100)).toEqual([]);
});
it('should drop whitespace-only chunks at boundaries', () => {
// Leading "\n\n" with a tight limit used to produce ["\n", ...] —
// a single newline is treated as empty by Telegram.
const chunks = splitMessage('\n\nAAAAAA', 3);
for (const c of chunks) expect(c.trim().length).toBeGreaterThan(0);
expect(chunks.join('')).toContain('AAA');
});
});
});
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TELEGRAM_API_BASE, TelegramApi, TelegramEditUnavailableError } from './api';
const BOT_TOKEN = 'test-bot-token';
const okResponse = (body: Record<string, unknown>) =>
new Response(JSON.stringify({ ok: true, result: body }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
});
const telegramErrorResponse = (errorCode: number, description: string) =>
new Response(JSON.stringify({ description, error_code: errorCode, ok: false }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
});
describe('TelegramApi HTML parse fallback', () => {
let fetchSpy: any;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('sendMessage retries without parse_mode when Telegram rejects HTML entities', async () => {
fetchSpy
.mockResolvedValueOnce(
telegramErrorResponse(
400,
'Bad Request: can\'t parse entities: Can\'t find end tag corresponding to start tag "b"',
),
)
.mockResolvedValueOnce(okResponse({ message_id: 42 }));
const api = new TelegramApi(BOT_TOKEN);
const result = await api.sendMessage('chat-1', '<b>broken html and the answer is 42');
expect(result).toEqual({ message_id: 42 });
expect(fetchSpy).toHaveBeenCalledTimes(2);
const retryCall = fetchSpy.mock.calls[1];
const retryBody = JSON.parse((retryCall[1] as RequestInit).body as string);
// Plain-text retry: parse_mode absent and tags stripped from text
expect(retryBody.parse_mode).toBeUndefined();
expect(retryBody.text).not.toContain('<b>');
expect(retryBody.text).toContain('the answer is 42');
});
it('editMessageText retries without parse_mode on HTML parse error', async () => {
fetchSpy
.mockResolvedValueOnce(
telegramErrorResponse(400, "Bad Request: can't parse entities: Unsupported start tag"),
)
.mockResolvedValueOnce(okResponse({ message_id: 42 }));
const api = new TelegramApi(BOT_TOKEN);
await api.editMessageText('chat-1', 42, '<b>broken');
expect(fetchSpy).toHaveBeenCalledTimes(2);
const retryBody = JSON.parse((fetchSpy.mock.calls[1][1] as RequestInit).body as string);
expect(retryBody.parse_mode).toBeUndefined();
expect(retryBody.text).toBe('broken');
});
it('editMessageText still ignores "message is not modified"', async () => {
fetchSpy.mockResolvedValueOnce(
telegramErrorResponse(400, 'Bad Request: message is not modified'),
);
const api = new TelegramApi(BOT_TOKEN);
await expect(api.editMessageText('chat-1', 42, 'same')).resolves.toBeUndefined();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('editMessageText throws TelegramEditUnavailableError when message cannot be edited', async () => {
fetchSpy.mockResolvedValueOnce(
telegramErrorResponse(400, 'Bad Request: message to edit not found'),
);
const api = new TelegramApi(BOT_TOKEN);
await expect(api.editMessageText('chat-1', 42, 'updated')).rejects.toBeInstanceOf(
TelegramEditUnavailableError,
);
});
it('TELEGRAM_API_BASE is exported', () => {
expect(TELEGRAM_API_BASE).toBe('https://api.telegram.org');
});
it('sendMessage refuses to call Telegram with empty text', async () => {
const api = new TelegramApi(BOT_TOKEN);
await expect(api.sendMessage('chat-1', ' \n\n ')).rejects.toThrow(/text is empty/);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('editMessageText refuses to call Telegram with empty text', async () => {
const api = new TelegramApi(BOT_TOKEN);
await expect(api.editMessageText('chat-1', 42, '\n')).rejects.toThrow(/text is empty/);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('retries once on transient network errors (ETIMEDOUT)', async () => {
// Simulates undici's "TypeError: fetch failed" wrapping an ETIMEDOUT cause —
// exactly the shape we saw in the production log.
const fetchFailed = Object.assign(new TypeError('fetch failed'), {
cause: { code: 'ETIMEDOUT' },
});
fetchSpy
.mockRejectedValueOnce(fetchFailed)
.mockResolvedValueOnce(okResponse({ message_id: 99 }));
const api = new TelegramApi(BOT_TOKEN);
const result = await api.sendMessage('chat-1', 'hello');
expect(result).toEqual({ message_id: 99 });
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('does not retry on non-transient errors (e.g. logical 400)', async () => {
fetchSpy.mockResolvedValueOnce(telegramErrorResponse(400, 'Bad Request: chat not found'));
const api = new TelegramApi(BOT_TOKEN);
await expect(api.sendMessage('chat-1', 'hello')).rejects.toThrow(/chat not found/);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('gives up after a single retry when the transient error persists', async () => {
const fetchFailed = Object.assign(new TypeError('fetch failed'), {
cause: { code: 'ETIMEDOUT' },
});
fetchSpy.mockRejectedValue(fetchFailed);
const api = new TelegramApi(BOT_TOKEN);
await expect(api.sendMessage('chat-1', 'hello')).rejects.toThrow(/fetch failed/);
// Original attempt + 1 retry = 2; never escalates further.
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});
+154 -31
View File
@@ -4,6 +4,71 @@ const log = debug('bot-platform:telegram:client');
export const TELEGRAM_API_BASE = 'https://api.telegram.org';
/**
* Hard cap per Telegram API request. Vercel functions have a finite total
* runtime and `handleCompletion` may serially call this once per chunk plus
* a possible edit→create fallback, so unbounded `fetch` (no default timeout
* in undici beyond ~5min) can wedge the whole callback.
*/
const TELEGRAM_FETCH_TIMEOUT_MS = 8000;
const isParseEntitiesError = (error: unknown): boolean => {
const msg = (error as { message?: string } | null)?.message;
return typeof msg === 'string' && msg.includes("can't parse entities");
};
const stripHTML = (html: string): string =>
html
.replaceAll(/<\/?[a-z][^>]*>/gi, '')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
/**
* Thrown when an edit cannot be retried (message is gone or beyond the edit
* window). Callers should fall back to sending a new message so the user
* still receives the content.
*/
export class TelegramEditUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'TelegramEditUnavailableError';
}
}
/**
* Transient network failures worth a single retry: connection-level timeouts
* (ETIMEDOUT, ECONNRESET, EAI_AGAIN), undici's generic "fetch failed", and
* the AbortSignal.timeout firing. Sustained outages will fail twice and bail.
*/
const isTransientNetworkError = (error: unknown): boolean => {
if (!error || typeof error !== 'object') return false;
const err = error as { name?: string; code?: string; message?: string; cause?: unknown };
if (err.name === 'TimeoutError' || err.name === 'AbortError') return true;
const codes = new Set(['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN', 'UND_ERR_SOCKET']);
if (err.code && codes.has(err.code)) return true;
// undici wraps low-level errors as `TypeError: fetch failed` with a `cause`.
if (err.message?.includes('fetch failed')) return true;
if (err.cause && typeof err.cause === 'object') {
const cause = err.cause as { code?: string; errors?: Array<{ code?: string }> };
if (cause.code && codes.has(cause.code)) return true;
if (cause.errors?.some((e) => e?.code && codes.has(e.code))) return true;
}
return false;
};
const isEditUnavailable = (error: unknown): boolean => {
const msg = (error as { message?: string } | null)?.message;
if (typeof msg !== 'string') return false;
return (
msg.includes('message to edit not found') ||
msg.includes("message can't be edited") ||
msg.includes('MESSAGE_ID_INVALID')
);
};
/**
* Lightweight platform client for Telegram Bot API operations used by
* callback and extension flows outside the Chat SDK adapter surface.
@@ -17,16 +82,39 @@ export class TelegramApi {
async sendMessage(chatId: string | number, text: string): Promise<{ message_id: number }> {
log('sendMessage: chatId=%s', chatId);
const data = await this.call('sendMessage', {
chat_id: chatId,
parse_mode: 'HTML',
text: this.truncateText(text),
});
return { message_id: data.result.message_id };
if (!text.trim()) {
// Telegram rejects empty / whitespace-only messages with 400 "message
// text is empty". Throwing here surfaces the bug at the call site
// instead of letting an upstream silent-catch drop the user's reply.
throw new Error('Telegram API sendMessage skipped: text is empty');
}
const truncated = this.truncateText(text);
try {
const data = await this.call('sendMessage', {
chat_id: chatId,
parse_mode: 'HTML',
text: truncated,
});
return { message_id: data.result.message_id };
} catch (error) {
// The HTML produced by markdownToTelegramHTML is best-effort — the LLM
// can emit content the converter can't always close cleanly. Falling
// back to plain text guarantees delivery instead of dropping the reply.
if (!isParseEntitiesError(error)) throw error;
log('sendMessage: HTML parse failed, retrying as plain text. chatId=%s', chatId);
const data = await this.call('sendMessage', {
chat_id: chatId,
text: this.truncateText(stripHTML(text)),
});
return { message_id: data.result.message_id };
}
}
async editMessageText(chatId: string | number, messageId: number, text: string): Promise<void> {
log('editMessageText: chatId=%s, messageId=%s', chatId, messageId);
if (!text.trim()) {
throw new Error('Telegram API editMessageText skipped: text is empty');
}
try {
await this.call('editMessageText', {
chat_id: chatId,
@@ -37,6 +125,29 @@ export class TelegramApi {
} catch (error: any) {
// Telegram returns 400 when the new content is identical to the current message — safe to ignore
if (error?.message?.includes('message is not modified')) return;
if (isParseEntitiesError(error)) {
log(
'editMessageText: HTML parse failed, retrying as plain text. chatId=%s, messageId=%s',
chatId,
messageId,
);
try {
await this.call('editMessageText', {
chat_id: chatId,
message_id: messageId,
text: this.truncateText(stripHTML(text)),
});
return;
} catch (retryError) {
if (isEditUnavailable(retryError)) {
throw new TelegramEditUnavailableError((retryError as Error).message);
}
throw retryError;
}
}
if (isEditUnavailable(error)) {
throw new TelegramEditUnavailableError(error.message);
}
throw error;
}
}
@@ -215,33 +326,45 @@ export class TelegramApi {
private async call(method: string, body: Record<string, unknown>): Promise<any> {
const url = `${TELEGRAM_API_BASE}/bot${this.botToken}/${method}`;
const payload = JSON.stringify(body);
const response = await fetch(url, {
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
const attempt = async (): Promise<any> => {
// Cap each request so a slow Telegram doesn't eat the whole Vercel
// function budget (multiple chunks call this serially during reply).
const response = await fetch(url, {
body: payload,
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal: AbortSignal.timeout(TELEGRAM_FETCH_TIMEOUT_MS),
});
if (!response.ok) {
const text = await response.text();
log('Telegram API error: method=%s, status=%d, body=%s', method, response.status, text);
throw new Error(`Telegram API ${method} failed: ${response.status} ${text}`);
if (!response.ok) {
const text = await response.text();
log('Telegram API error: method=%s, status=%d, body=%s', method, response.status, text);
throw new Error(`Telegram API ${method} failed: ${response.status} ${text}`);
}
const data = await response.json();
// Telegram can return HTTP 200 with {"ok": false, ...} for logical errors
if (data.ok === false) {
const desc = data.description || 'Unknown error';
log(
'Telegram API logical error: method=%s, error_code=%d, description=%s',
method,
data.error_code,
desc,
);
throw new Error(`Telegram API ${method} failed: ${data.error_code} ${desc}`);
}
return data;
};
try {
return await attempt();
} catch (error) {
if (!isTransientNetworkError(error)) throw error;
log('Telegram API %s: transient network error, retrying once: %O', method, error);
return await attempt();
}
const data = await response.json();
// Telegram can return HTTP 200 with {"ok": false, ...} for logical errors
if (data.ok === false) {
const desc = data.description || 'Unknown error';
log(
'Telegram API logical error: method=%s, error_code=%d, description=%s',
method,
data.error_code,
desc,
);
throw new Error(`Telegram API ${method} failed: ${data.error_code} ${desc}`);
}
return data;
}
}
+11 -3
View File
@@ -12,14 +12,18 @@ const EMOJI_THINKING = '💭';
const DEFAULT_CHAR_LIMIT = 1800;
export function splitMessage(text: string, limit = DEFAULT_CHAR_LIMIT): string[] {
if (text.length <= limit) return [text];
if (text.length <= limit) {
// Whitespace-only input would be rejected by Telegram as "message text is empty",
// so drop it here rather than letting downstream make a guaranteed-failing API call.
return text.trim() ? [text] : [];
}
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= limit) {
chunks.push(remaining);
if (remaining.trim()) chunks.push(remaining);
break;
}
@@ -30,7 +34,11 @@ export function splitMessage(text: string, limit = DEFAULT_CHAR_LIMIT): string[]
// Hard cut
if (splitAt <= 0) splitAt = limit;
chunks.push(remaining.slice(0, splitAt));
const chunk = remaining.slice(0, splitAt);
// A boundary near the start (e.g. text begins with "\n\n") can produce a
// whitespace-only chunk; emitting it would trigger Telegram's empty-text
// 400 and silently drop the rest of the reply.
if (chunk.trim()) chunks.push(chunk);
remaining = remaining.slice(splitAt).replace(/^\n+/, '');
}