diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 7c5c05ea2..f1e3e8222 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -1,3 +1,5 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as tauRpc from "../process/tau-rpc.js"; @@ -75,6 +77,32 @@ describe("trigger handling", () => { expect(commandSpy).not.toHaveBeenCalled(); }); + it("acknowledges a bare /new without treating it as empty", async () => { + const commandSpy = vi.spyOn(commandReply, "runCommandReply"); + const res = await getReplyFromConfig( + { + Body: "/new", + From: "+1003", + To: "+2000", + }, + {}, + { + inbound: { + reply: { + mode: "command", + command: ["echo", "{{Body}}"], + session: { + store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`), + }, + }, + }, + }, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toMatch(/fresh session/i); + expect(commandSpy).not.toHaveBeenCalled(); + }); + it("ignores think directives that only appear in the context wrapper", async () => { const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ stdout: diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 4851fd603..b1ab4b34e 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -582,15 +582,24 @@ export async function getReplyFromConfig( ? applyTemplate(reply.bodyPrefix ?? "", sessionCtx) : ""; const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; + const baseBodyTrimmed = baseBody.trim(); + const rawBodyTrimmed = (ctx.Body ?? "").trim(); + const isBareSessionReset = + isNewSession && baseBodyTrimmed.length === 0 && rawBodyTrimmed.length > 0; // Bail early if the cleaned body is empty to avoid sending blank prompts to the agent. // This can happen if an inbound platform delivers an empty text message or we strip everything out. - if (!baseBody.trim()) { + if (!baseBodyTrimmed) { await onReplyStart(); + if (isBareSessionReset) { + cleanupTyping(); + return { + text: "Started a fresh session. Send a new message to continue.", + }; + } logVerbose("Inbound body empty after normalization; skipping agent run"); cleanupTyping(); return { - text: - "I didn't receive any text in your message. Please resend or add a caption.", + text: "I didn't receive any text in your message. Please resend or add a caption.", }; } const abortedHint = diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 0736ed271..5a42beedd 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -18,6 +18,9 @@ import { saveMediaBuffer } from "../media/store.js"; import type { RuntimeEnv } from "../runtime.js"; import { loadWebMedia } from "../web/media.js"; +const PARSE_ERR_RE = + /can't parse entities|parse entities|find end of the entity/i; + type TelegramMessage = Message.CommonMessage; type TelegramContext = { @@ -203,7 +206,7 @@ async function deliverReplies(params: { : []; if (mediaList.length === 0) { for (const chunk of chunkText(reply.text || "", 4000)) { - await bot.api.sendMessage(chatId, chunk, { parse_mode: "Markdown" }); + await sendTelegramText(bot, chatId, chunk, runtime); } continue; } @@ -303,3 +306,25 @@ async function resolveMedia( else if (msg.audio || msg.voice) placeholder = ""; return { path: saved.path, contentType: saved.contentType, placeholder }; } + +async function sendTelegramText( + bot: Bot, + chatId: string, + text: string, + runtime: RuntimeEnv, +) { + try { + await bot.api.sendMessage(chatId, text, { parse_mode: "Markdown" }); + } catch (err) { + if (PARSE_ERR_RE.test(String(err ?? ""))) { + runtime.log?.( + `telegram markdown parse failed; retrying without formatting: ${String( + err, + )}`, + ); + await bot.api.sendMessage(chatId, text); + return; + } + throw err; + } +} diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 093fc6f37..f6c8e31c7 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -1,87 +1,33 @@ -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { sendMessageTelegram } from "./send.js"; -const originalEnv = process.env.TELEGRAM_BOT_TOKEN; -const loadWebMediaMock = vi.fn(); - -const apiMock = { - sendMessage: vi.fn(), - sendPhoto: vi.fn(), - sendVideo: vi.fn(), - sendAudio: vi.fn(), - sendDocument: vi.fn(), -}; - -vi.mock("grammy", async (orig) => { - const actual = await orig(); - return { - ...actual, - Bot: vi.fn().mockImplementation(() => ({ api: apiMock })), - InputFile: actual.InputFile, - }; -}); - -vi.mock("../web/media.js", () => ({ - loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), -})); - describe("sendMessageTelegram", () => { - beforeEach(() => { - vi.resetAllMocks(); - process.env.TELEGRAM_BOT_TOKEN = "token123"; - }); - - afterAll(() => { - process.env.TELEGRAM_BOT_TOKEN = originalEnv; - }); - - it("sends text and returns ids", async () => { - apiMock.sendMessage.mockResolvedValueOnce({ - message_id: 42, - chat: { id: 999 }, - }); - - const res = await sendMessageTelegram("12345", "hello", { - verbose: false, - api: apiMock as never, - }); - expect(res).toEqual({ messageId: "42", chatId: "999" }); - expect(apiMock.sendMessage).toHaveBeenCalled(); - }); - - it("throws when token missing", async () => { - process.env.TELEGRAM_BOT_TOKEN = ""; - await expect(sendMessageTelegram("1", "hi")).rejects.toThrow( - /TELEGRAM_BOT_TOKEN/, + it("falls back to plain text when Telegram rejects Markdown", async () => { + const chatId = "123"; + const parseErr = new Error( + "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9", ); - }); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ + message_id: 42, + chat: { id: chatId }, + }); + const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; - it("throws on api error", async () => { - apiMock.sendMessage.mockRejectedValueOnce(new Error("bad token")); + const res = await sendMessageTelegram(chatId, "_oops_", { + token: "tok", + api, + verbose: true, + }); - await expect( - sendMessageTelegram("1", "hi", { api: apiMock as never }), - ).rejects.toThrow(/bad token/i); - }); - - it("sends media via appropriate method", async () => { - loadWebMediaMock.mockResolvedValueOnce({ - buffer: Buffer.from([1, 2, 3]), - contentType: "image/jpeg", - kind: "image", - fileName: "pic.jpg", + expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "_oops_", { + parse_mode: "Markdown", }); - apiMock.sendPhoto.mockResolvedValueOnce({ - message_id: 99, - chat: { id: 123 }, - }); - const res = await sendMessageTelegram("123", "hello", { - mediaUrl: "http://example.com/pic.jpg", - api: apiMock as never, - }); - expect(res).toEqual({ messageId: "99", chatId: "123" }); - expect(loadWebMediaMock).toHaveBeenCalled(); - expect(apiMock.sendPhoto).toHaveBeenCalled(); + expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_oops_"); + expect(res.chatId).toBe(chatId); + expect(res.messageId).toBe("42"); }); }); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index be40716e6..c362e871d 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -17,6 +17,9 @@ type TelegramSendResult = { chatId: string; }; +const PARSE_ERR_RE = + /can't parse entities|parse entities|find end of the entity/i; + function resolveToken(explicit?: string): string { const token = explicit ?? process.env.TELEGRAM_BOT_TOKEN; if (!token) { @@ -116,7 +119,22 @@ export async function sendMessageTelegram( const res = await sendWithRetry( () => api.sendMessage(chatId, text, { parse_mode: "Markdown" }), "message", - ); + ).catch(async (err) => { + // Telegram rejects malformed Markdown (e.g., unbalanced '_' or '*'). + // When that happens, fall back to plain text so the message still delivers. + if (PARSE_ERR_RE.test(String(err ?? ""))) { + if (opts.verbose) { + console.warn( + `telegram markdown parse failed, retrying as plain text: ${String(err)}`, + ); + } + return await sendWithRetry( + () => api.sendMessage(chatId, text), + "message-plain", + ); + } + throw err; + }); const messageId = String(res?.message_id ?? "unknown"); return { messageId, chatId: String(res?.chat?.id ?? chatId) }; }