fix(auto-reply): acknowledge reset triggers
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import * as tauRpc from "../process/tau-rpc.js";
|
import * as tauRpc from "../process/tau-rpc.js";
|
||||||
@@ -75,6 +77,32 @@ describe("trigger handling", () => {
|
|||||||
expect(commandSpy).not.toHaveBeenCalled();
|
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 () => {
|
it("ignores think directives that only appear in the context wrapper", async () => {
|
||||||
const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
|
const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
|
||||||
stdout:
|
stdout:
|
||||||
|
|||||||
@@ -582,15 +582,24 @@ export async function getReplyFromConfig(
|
|||||||
? applyTemplate(reply.bodyPrefix ?? "", sessionCtx)
|
? applyTemplate(reply.bodyPrefix ?? "", sessionCtx)
|
||||||
: "";
|
: "";
|
||||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
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.
|
// 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.
|
// This can happen if an inbound platform delivers an empty text message or we strip everything out.
|
||||||
if (!baseBody.trim()) {
|
if (!baseBodyTrimmed) {
|
||||||
await onReplyStart();
|
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");
|
logVerbose("Inbound body empty after normalization; skipping agent run");
|
||||||
cleanupTyping();
|
cleanupTyping();
|
||||||
return {
|
return {
|
||||||
text:
|
text: "I didn't receive any text in your message. Please resend or add a caption.",
|
||||||
"I didn't receive any text in your message. Please resend or add a caption.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const abortedHint =
|
const abortedHint =
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { saveMediaBuffer } from "../media/store.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { loadWebMedia } from "../web/media.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 TelegramMessage = Message.CommonMessage;
|
||||||
|
|
||||||
type TelegramContext = {
|
type TelegramContext = {
|
||||||
@@ -203,7 +206,7 @@ async function deliverReplies(params: {
|
|||||||
: [];
|
: [];
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkText(reply.text || "", 4000)) {
|
for (const chunk of chunkText(reply.text || "", 4000)) {
|
||||||
await bot.api.sendMessage(chatId, chunk, { parse_mode: "Markdown" });
|
await sendTelegramText(bot, chatId, chunk, runtime);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -303,3 +306,25 @@ async function resolveMedia(
|
|||||||
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
||||||
return { path: saved.path, contentType: saved.contentType, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
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", () => {
|
describe("sendMessageTelegram", () => {
|
||||||
beforeEach(() => {
|
it("falls back to plain text when Telegram rejects Markdown", async () => {
|
||||||
vi.resetAllMocks();
|
const chatId = "123";
|
||||||
process.env.TELEGRAM_BOT_TOKEN = "token123";
|
const parseErr = new Error(
|
||||||
});
|
"400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9",
|
||||||
|
|
||||||
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/,
|
|
||||||
);
|
);
|
||||||
});
|
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 () => {
|
const res = await sendMessageTelegram(chatId, "_oops_", {
|
||||||
apiMock.sendMessage.mockRejectedValueOnce(new Error("bad token"));
|
token: "tok",
|
||||||
|
api,
|
||||||
|
verbose: true,
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "_oops_", {
|
||||||
sendMessageTelegram("1", "hi", { api: apiMock as never }),
|
parse_mode: "Markdown",
|
||||||
).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",
|
|
||||||
});
|
});
|
||||||
apiMock.sendPhoto.mockResolvedValueOnce({
|
expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_oops_");
|
||||||
message_id: 99,
|
expect(res.chatId).toBe(chatId);
|
||||||
chat: { id: 123 },
|
expect(res.messageId).toBe("42");
|
||||||
});
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ type TelegramSendResult = {
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PARSE_ERR_RE =
|
||||||
|
/can't parse entities|parse entities|find end of the entity/i;
|
||||||
|
|
||||||
function resolveToken(explicit?: string): string {
|
function resolveToken(explicit?: string): string {
|
||||||
const token = explicit ?? process.env.TELEGRAM_BOT_TOKEN;
|
const token = explicit ?? process.env.TELEGRAM_BOT_TOKEN;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -116,7 +119,22 @@ export async function sendMessageTelegram(
|
|||||||
const res = await sendWithRetry(
|
const res = await sendWithRetry(
|
||||||
() => api.sendMessage(chatId, text, { parse_mode: "Markdown" }),
|
() => api.sendMessage(chatId, text, { parse_mode: "Markdown" }),
|
||||||
"message",
|
"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");
|
const messageId = String(res?.message_id ?? "unknown");
|
||||||
return { messageId, chatId: String(res?.chat?.id ?? chatId) };
|
return { messageId, chatId: String(res?.chat?.id ?? chatId) };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user