mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d38d59e8e | |||
| 41c71655b6 |
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'");
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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+/, '');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user