refactor: unify reply dispatch across providers
This commit is contained in:
82
src/auto-reply/reply/reply-dispatcher.test.ts
Normal file
82
src/auto-reply/reply/reply-dispatcher.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
||||
|
||||
describe("createReplyDispatcher", () => {
|
||||
it("drops empty payloads and silent tokens without media", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue(undefined);
|
||||
const dispatcher = createReplyDispatcher({ deliver });
|
||||
|
||||
expect(dispatcher.sendFinalReply({})).toBe(false);
|
||||
expect(dispatcher.sendFinalReply({ text: " " })).toBe(false);
|
||||
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false);
|
||||
|
||||
await dispatcher.waitForIdle();
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strips heartbeat tokens and applies responsePrefix", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue(undefined);
|
||||
const onHeartbeatStrip = vi.fn();
|
||||
const dispatcher = createReplyDispatcher({
|
||||
deliver,
|
||||
responsePrefix: "PFX",
|
||||
onHeartbeatStrip,
|
||||
});
|
||||
|
||||
expect(dispatcher.sendFinalReply({ text: HEARTBEAT_TOKEN })).toBe(false);
|
||||
expect(
|
||||
dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` }),
|
||||
).toBe(true);
|
||||
await dispatcher.waitForIdle();
|
||||
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
expect(deliver.mock.calls[0][0].text).toBe("PFX hello");
|
||||
expect(onHeartbeatStrip).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue(undefined);
|
||||
const dispatcher = createReplyDispatcher({
|
||||
deliver,
|
||||
responsePrefix: "PFX",
|
||||
});
|
||||
|
||||
expect(
|
||||
dispatcher.sendFinalReply({
|
||||
text: "PFX already",
|
||||
mediaUrl: "file:///tmp/photo.jpg",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
dispatcher.sendFinalReply({
|
||||
text: HEARTBEAT_TOKEN,
|
||||
mediaUrl: "file:///tmp/photo.jpg",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await dispatcher.waitForIdle();
|
||||
|
||||
expect(deliver).toHaveBeenCalledTimes(2);
|
||||
expect(deliver.mock.calls[0][0].text).toBe("PFX already");
|
||||
expect(deliver.mock.calls[1][0].text).toBe("");
|
||||
});
|
||||
|
||||
it("preserves ordering across tool, block, and final replies", async () => {
|
||||
const delivered: string[] = [];
|
||||
const deliver = vi.fn(async (_payload, info) => {
|
||||
delivered.push(info.kind);
|
||||
if (info.kind === "tool") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
});
|
||||
const dispatcher = createReplyDispatcher({ deliver });
|
||||
|
||||
dispatcher.sendToolResult({ text: "tool" });
|
||||
dispatcher.sendBlockReply({ text: "block" });
|
||||
dispatcher.sendFinalReply({ text: "final" });
|
||||
|
||||
await dispatcher.waitForIdle();
|
||||
expect(delivered).toEqual(["tool", "block", "final"]);
|
||||
});
|
||||
});
|
||||
99
src/auto-reply/reply/reply-dispatcher.ts
Normal file
99
src/auto-reply/reply/reply-dispatcher.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
export type ReplyDispatchKind = "tool" | "block" | "final";
|
||||
|
||||
type ReplyDispatchErrorHandler = (
|
||||
err: unknown,
|
||||
info: { kind: ReplyDispatchKind },
|
||||
) => void;
|
||||
|
||||
type ReplyDispatchDeliverer = (
|
||||
payload: ReplyPayload,
|
||||
info: { kind: ReplyDispatchKind },
|
||||
) => Promise<void>;
|
||||
|
||||
export type ReplyDispatcherOptions = {
|
||||
deliver: ReplyDispatchDeliverer;
|
||||
responsePrefix?: string;
|
||||
onHeartbeatStrip?: () => void;
|
||||
onError?: ReplyDispatchErrorHandler;
|
||||
};
|
||||
|
||||
type ReplyDispatcher = {
|
||||
sendToolResult: (payload: ReplyPayload) => boolean;
|
||||
sendBlockReply: (payload: ReplyPayload) => boolean;
|
||||
sendFinalReply: (payload: ReplyPayload) => boolean;
|
||||
waitForIdle: () => Promise<void>;
|
||||
getQueuedCounts: () => Record<ReplyDispatchKind, number>;
|
||||
};
|
||||
|
||||
function normalizeReplyPayload(
|
||||
payload: ReplyPayload,
|
||||
opts: Pick<ReplyDispatcherOptions, "responsePrefix" | "onHeartbeatStrip">,
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = Boolean(
|
||||
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const trimmed = payload.text?.trim() ?? "";
|
||||
if (!trimmed && !hasMedia) return null;
|
||||
|
||||
// Avoid sending the explicit silent token when no media is attached.
|
||||
if (trimmed === SILENT_REPLY_TOKEN && !hasMedia) return null;
|
||||
|
||||
let text = payload.text ?? undefined;
|
||||
if (text && !trimmed) {
|
||||
// Keep empty text when media exists so media-only replies still send.
|
||||
text = "";
|
||||
}
|
||||
if (text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||
if (stripped.shouldSkip && !hasMedia) return null;
|
||||
text = stripped.text;
|
||||
}
|
||||
|
||||
if (
|
||||
opts.responsePrefix &&
|
||||
text &&
|
||||
text.trim() !== HEARTBEAT_TOKEN &&
|
||||
!text.startsWith(opts.responsePrefix)
|
||||
) {
|
||||
text = `${opts.responsePrefix} ${text}`;
|
||||
}
|
||||
|
||||
return { ...payload, text };
|
||||
}
|
||||
|
||||
export function createReplyDispatcher(
|
||||
options: ReplyDispatcherOptions,
|
||||
): ReplyDispatcher {
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
// Serialize outbound replies to preserve tool/block/final order.
|
||||
const queuedCounts: Record<ReplyDispatchKind, number> = {
|
||||
tool: 0,
|
||||
block: 0,
|
||||
final: 0,
|
||||
};
|
||||
|
||||
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
|
||||
const normalized = normalizeReplyPayload(payload, options);
|
||||
if (!normalized) return false;
|
||||
queuedCounts[kind] += 1;
|
||||
sendChain = sendChain
|
||||
.then(() => options.deliver(normalized, { kind }))
|
||||
.catch((err) => {
|
||||
options.onError?.(err, { kind });
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
sendToolResult: (payload) => enqueue("tool", payload),
|
||||
sendBlockReply: (payload) => enqueue("block", payload),
|
||||
sendFinalReply: (payload) => enqueue("final", payload),
|
||||
waitForIdle: () => sendChain,
|
||||
getQueuedCounts: () => ({ ...queuedCounts }),
|
||||
};
|
||||
}
|
||||
151
src/discord/monitor.tool-result.test.ts
Normal file
151
src/discord/monitor.tool-result.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { monitorDiscordProvider } from "./monitor.js";
|
||||
|
||||
const sendMock = vi.fn();
|
||||
const replyMock = vi.fn();
|
||||
const updateLastRouteMock = vi.fn();
|
||||
let config: Record<string, unknown> = {};
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
|
||||
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||
resolveSessionKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("discord.js", () => {
|
||||
const handlers = new Map<string, Set<(...args: unknown[]) => void>>();
|
||||
let lastClient: Client | null = null;
|
||||
|
||||
class Client {
|
||||
user = { id: "bot-id", tag: "bot#1" };
|
||||
constructor() {
|
||||
lastClient = this;
|
||||
}
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
if (!handlers.has(event)) handlers.set(event, new Set());
|
||||
handlers.get(event)?.add(handler);
|
||||
}
|
||||
once(event: string, handler: (...args: unknown[]) => void) {
|
||||
this.on(event, handler);
|
||||
}
|
||||
off(event: string, handler: (...args: unknown[]) => void) {
|
||||
handlers.get(event)?.delete(handler);
|
||||
}
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const handler of handlers.get(event) ?? []) {
|
||||
void handler(...args);
|
||||
}
|
||||
}
|
||||
login = vi.fn().mockResolvedValue(undefined);
|
||||
destroy = vi.fn().mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
Client,
|
||||
__getLastClient: () => lastClient,
|
||||
Events: {
|
||||
ClientReady: "ready",
|
||||
Error: "error",
|
||||
MessageCreate: "messageCreate",
|
||||
MessageReactionAdd: "reactionAdd",
|
||||
MessageReactionRemove: "reactionRemove",
|
||||
},
|
||||
ChannelType: {
|
||||
DM: "dm",
|
||||
GroupDM: "group_dm",
|
||||
GuildText: "guild_text",
|
||||
},
|
||||
MessageType: {
|
||||
Default: "default",
|
||||
ChatInputCommand: "chat_command",
|
||||
ContextMenuCommand: "context_command",
|
||||
},
|
||||
GatewayIntentBits: {},
|
||||
Partials: {},
|
||||
};
|
||||
});
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
async function waitForClient() {
|
||||
const discord = (await import("discord.js")) as unknown as {
|
||||
__getLastClient: () => { emit: (...args: unknown[]) => void } | null;
|
||||
};
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const client = discord.__getLastClient();
|
||||
if (client) return client;
|
||||
await flush();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
config = {
|
||||
messages: { responsePrefix: "PFX" },
|
||||
discord: { dm: { enabled: true } },
|
||||
routing: { allowFrom: [] },
|
||||
};
|
||||
sendMock.mockReset().mockResolvedValue(undefined);
|
||||
replyMock.mockReset();
|
||||
updateLastRouteMock.mockReset();
|
||||
});
|
||||
|
||||
describe("monitorDiscordProvider tool results", () => {
|
||||
it("sends tool summaries with responsePrefix", async () => {
|
||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
||||
await opts?.onToolResult?.({ text: "tool update" });
|
||||
return { text: "final reply" };
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const run = monitorDiscordProvider({
|
||||
token: "token",
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
const discord = await import("discord.js");
|
||||
const client = await waitForClient();
|
||||
if (!client) throw new Error("Discord client not created");
|
||||
|
||||
client.emit(discord.Events.MessageCreate, {
|
||||
id: "m1",
|
||||
content: "hello",
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
channelId: "c1",
|
||||
channel: {
|
||||
type: discord.ChannelType.DM,
|
||||
isSendable: () => false,
|
||||
},
|
||||
guild: undefined,
|
||||
mentions: { has: () => false },
|
||||
attachments: { first: () => undefined },
|
||||
type: discord.MessageType.Default,
|
||||
createdTimestamp: Date.now(),
|
||||
});
|
||||
|
||||
await flush();
|
||||
controller.abort();
|
||||
await run;
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
|
||||
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type {
|
||||
@@ -532,39 +533,36 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}
|
||||
|
||||
let didSendReply = false;
|
||||
let blockSendChain: Promise<void> = Promise.resolve();
|
||||
const sendBlockReply = (payload: ReplyPayload) => {
|
||||
if (
|
||||
!payload?.text &&
|
||||
!payload?.mediaUrl &&
|
||||
!(payload?.mediaUrls?.length ?? 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
blockSendChain = blockSendChain
|
||||
.then(async () => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target: replyTarget,
|
||||
token,
|
||||
runtime,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
didSendReply = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error?.(
|
||||
danger(`discord block reply failed: ${String(err)}`),
|
||||
);
|
||||
const dispatcher = createReplyDispatcher({
|
||||
responsePrefix: cfg.messages?.responsePrefix,
|
||||
deliver: async (payload) => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target: replyTarget,
|
||||
token,
|
||||
runtime,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
};
|
||||
didSendReply = true;
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
danger(`discord ${info.kind} reply failed: ${String(err)}`),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const replyResult = await getReplyFromConfig(
|
||||
ctxPayload,
|
||||
{
|
||||
onReplyStart: () => sendTyping(message),
|
||||
onBlockReply: sendBlockReply,
|
||||
onToolResult: (payload) => {
|
||||
dispatcher.sendToolResult(payload);
|
||||
},
|
||||
onBlockReply: (payload) => {
|
||||
dispatcher.sendBlockReply(payload);
|
||||
},
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
@@ -573,8 +571,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
? replyResult
|
||||
: [replyResult]
|
||||
: [];
|
||||
await blockSendChain;
|
||||
if (replies.length === 0) {
|
||||
let queuedFinal = false;
|
||||
for (const reply of replies) {
|
||||
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||
}
|
||||
await dispatcher.waitForIdle();
|
||||
if (!queuedFinal) {
|
||||
if (
|
||||
isGuildMessage &&
|
||||
shouldClearHistory &&
|
||||
@@ -585,19 +587,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await deliverReplies({
|
||||
replies,
|
||||
target: replyTarget,
|
||||
token,
|
||||
runtime,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
didSendReply = true;
|
||||
if (shouldLogVerbose()) {
|
||||
const finalCount = dispatcher.getQueuedCounts().final;
|
||||
logVerbose(
|
||||
`discord: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -139,6 +139,41 @@ describe("monitorIMessageProvider", () => {
|
||||
expect(replyMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefixes tool and final replies with responsePrefix", async () => {
|
||||
config = {
|
||||
...config,
|
||||
messages: { responsePrefix: "PFX" },
|
||||
};
|
||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
||||
await opts?.onToolResult?.({ text: "tool update" });
|
||||
return { text: "final reply" };
|
||||
});
|
||||
const run = monitorIMessageProvider();
|
||||
await waitForSubscribe();
|
||||
|
||||
notificationHandler?.({
|
||||
method: "message",
|
||||
params: {
|
||||
message: {
|
||||
id: 7,
|
||||
chat_id: 77,
|
||||
sender: "+15550001111",
|
||||
is_from_me: false,
|
||||
text: "hello",
|
||||
is_group: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flush();
|
||||
closeResolve?.();
|
||||
await run;
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
|
||||
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
||||
});
|
||||
|
||||
it("delivers group replies when mentioned", async () => {
|
||||
replyMock.mockResolvedValueOnce({ text: "yo" });
|
||||
const run = monitorIMessageProvider();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
@@ -267,36 +268,35 @@ export async function monitorIMessageProvider(
|
||||
);
|
||||
}
|
||||
|
||||
let blockSendChain: Promise<void> = Promise.resolve();
|
||||
const sendBlockReply = (payload: ReplyPayload) => {
|
||||
if (
|
||||
!payload?.text &&
|
||||
!payload?.mediaUrl &&
|
||||
!(payload?.mediaUrls?.length ?? 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
blockSendChain = blockSendChain
|
||||
.then(async () => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target: ctxPayload.To,
|
||||
client,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error?.(
|
||||
danger(`imessage block reply failed: ${String(err)}`),
|
||||
);
|
||||
const dispatcher = createReplyDispatcher({
|
||||
responsePrefix: cfg.messages?.responsePrefix,
|
||||
deliver: async (payload) => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target: ctxPayload.To,
|
||||
client,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
});
|
||||
};
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
danger(`imessage ${info.kind} reply failed: ${String(err)}`),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const replyResult = await getReplyFromConfig(
|
||||
ctxPayload,
|
||||
{ onBlockReply: sendBlockReply },
|
||||
{
|
||||
onToolResult: (payload) => {
|
||||
dispatcher.sendToolResult(payload);
|
||||
},
|
||||
onBlockReply: (payload) => {
|
||||
dispatcher.sendBlockReply(payload);
|
||||
},
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
const replies = replyResult
|
||||
@@ -304,17 +304,12 @@ export async function monitorIMessageProvider(
|
||||
? replyResult
|
||||
: [replyResult]
|
||||
: [];
|
||||
await blockSendChain;
|
||||
if (replies.length === 0) return;
|
||||
|
||||
await deliverReplies({
|
||||
replies,
|
||||
target: ctxPayload.To,
|
||||
client,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
});
|
||||
let queuedFinal = false;
|
||||
for (const reply of replies) {
|
||||
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||
}
|
||||
await dispatcher.waitForIdle();
|
||||
if (!queuedFinal) return;
|
||||
};
|
||||
|
||||
const client = await createIMessageRpcClient({
|
||||
|
||||
96
src/signal/monitor.tool-result.test.ts
Normal file
96
src/signal/monitor.tool-result.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { monitorSignalProvider } from "./monitor.js";
|
||||
|
||||
const sendMock = vi.fn();
|
||||
const replyMock = vi.fn();
|
||||
const updateLastRouteMock = vi.fn();
|
||||
let config: Record<string, unknown> = {};
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
|
||||
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||
}));
|
||||
|
||||
const streamMock = vi.fn();
|
||||
const signalCheckMock = vi.fn();
|
||||
const signalRpcRequestMock = vi.fn();
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
streamSignalEvents: (...args: unknown[]) => streamMock(...args),
|
||||
signalCheck: (...args: unknown[]) => signalCheckMock(...args),
|
||||
signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./daemon.js", () => ({
|
||||
spawnSignalDaemon: vi.fn(() => ({ stop: vi.fn() })),
|
||||
}));
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
beforeEach(() => {
|
||||
config = {
|
||||
messages: { responsePrefix: "PFX" },
|
||||
signal: { autoStart: false },
|
||||
routing: { allowFrom: [] },
|
||||
};
|
||||
sendMock.mockReset().mockResolvedValue(undefined);
|
||||
replyMock.mockReset();
|
||||
updateLastRouteMock.mockReset();
|
||||
streamMock.mockReset();
|
||||
signalCheckMock.mockReset().mockResolvedValue({});
|
||||
signalRpcRequestMock.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("monitorSignalProvider tool results", () => {
|
||||
it("sends tool summaries with responsePrefix", async () => {
|
||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
||||
await opts?.onToolResult?.({ text: "tool update" });
|
||||
return { text: "final reply" };
|
||||
});
|
||||
|
||||
streamMock.mockImplementation(async ({ onEvent }) => {
|
||||
const payload = {
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
},
|
||||
},
|
||||
};
|
||||
await onEvent({
|
||||
event: "receive",
|
||||
data: JSON.stringify(payload),
|
||||
});
|
||||
});
|
||||
|
||||
await monitorSignalProvider({
|
||||
autoStart: false,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
|
||||
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
@@ -379,37 +380,36 @@ export async function monitorSignalProvider(
|
||||
);
|
||||
}
|
||||
|
||||
let blockSendChain: Promise<void> = Promise.resolve();
|
||||
const sendBlockReply = (payload: ReplyPayload) => {
|
||||
if (
|
||||
!payload?.text &&
|
||||
!payload?.mediaUrl &&
|
||||
!(payload?.mediaUrls?.length ?? 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
blockSendChain = blockSendChain
|
||||
.then(async () => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target: ctxPayload.To,
|
||||
baseUrl,
|
||||
account,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error?.(
|
||||
danger(`signal block reply failed: ${String(err)}`),
|
||||
);
|
||||
const dispatcher = createReplyDispatcher({
|
||||
responsePrefix: cfg.messages?.responsePrefix,
|
||||
deliver: async (payload) => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target: ctxPayload.To,
|
||||
baseUrl,
|
||||
account,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
});
|
||||
};
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
danger(`signal ${info.kind} reply failed: ${String(err)}`),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const replyResult = await getReplyFromConfig(
|
||||
ctxPayload,
|
||||
{ onBlockReply: sendBlockReply },
|
||||
{
|
||||
onToolResult: (payload) => {
|
||||
dispatcher.sendToolResult(payload);
|
||||
},
|
||||
onBlockReply: (payload) => {
|
||||
dispatcher.sendBlockReply(payload);
|
||||
},
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
const replies = replyResult
|
||||
@@ -417,18 +417,12 @@ export async function monitorSignalProvider(
|
||||
? replyResult
|
||||
: [replyResult]
|
||||
: [];
|
||||
await blockSendChain;
|
||||
if (replies.length === 0) return;
|
||||
|
||||
await deliverReplies({
|
||||
replies,
|
||||
target: ctxPayload.To,
|
||||
baseUrl,
|
||||
account,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
});
|
||||
let queuedFinal = false;
|
||||
for (const reply of replies) {
|
||||
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||
}
|
||||
await dispatcher.waitForIdle();
|
||||
if (!queuedFinal) return;
|
||||
};
|
||||
|
||||
await streamSignalEvents({
|
||||
|
||||
125
src/slack/monitor.tool-result.test.ts
Normal file
125
src/slack/monitor.tool-result.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { monitorSlackProvider } from "./monitor.js";
|
||||
|
||||
const sendMock = vi.fn();
|
||||
const replyMock = vi.fn();
|
||||
const updateLastRouteMock = vi.fn();
|
||||
let config: Record<string, unknown> = {};
|
||||
const getSlackHandlers = () =>
|
||||
(
|
||||
globalThis as {
|
||||
__slackHandlers?: Map<string, (args: unknown) => Promise<void>>;
|
||||
}
|
||||
).__slackHandlers;
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageSlack: (...args: unknown[]) => sendMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
|
||||
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||
resolveSessionKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@slack/bolt", () => {
|
||||
const handlers = new Map<string, (args: unknown) => Promise<void>>();
|
||||
(globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers =
|
||||
handlers;
|
||||
class App {
|
||||
client = {
|
||||
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
|
||||
conversations: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
channel: { name: "dm", is_im: true },
|
||||
}),
|
||||
},
|
||||
users: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
user: { profile: { display_name: "Ada" } },
|
||||
}),
|
||||
},
|
||||
};
|
||||
event(name: string, handler: (args: unknown) => Promise<void>) {
|
||||
handlers.set(name, handler);
|
||||
}
|
||||
command() {
|
||||
/* no-op */
|
||||
}
|
||||
start = vi.fn().mockResolvedValue(undefined);
|
||||
stop = vi.fn().mockResolvedValue(undefined);
|
||||
}
|
||||
return { default: { App } };
|
||||
});
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
async function waitForEvent(name: string) {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
if (getSlackHandlers()?.has(name)) return;
|
||||
await flush();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
config = {
|
||||
messages: { responsePrefix: "PFX" },
|
||||
slack: { dm: { enabled: true }, groupDm: { enabled: false } },
|
||||
routing: { allowFrom: [] },
|
||||
};
|
||||
sendMock.mockReset().mockResolvedValue(undefined);
|
||||
replyMock.mockReset();
|
||||
updateLastRouteMock.mockReset();
|
||||
});
|
||||
|
||||
describe("monitorSlackProvider tool results", () => {
|
||||
it("sends tool summaries with responsePrefix", async () => {
|
||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
||||
await opts?.onToolResult?.({ text: "tool update" });
|
||||
return { text: "final reply" };
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const run = monitorSlackProvider({
|
||||
botToken: "bot-token",
|
||||
appToken: "app-token",
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
await handler({
|
||||
event: {
|
||||
type: "message",
|
||||
user: "U1",
|
||||
text: "hello",
|
||||
ts: "123",
|
||||
channel: "C1",
|
||||
channel_type: "im",
|
||||
},
|
||||
});
|
||||
|
||||
await flush();
|
||||
controller.abort();
|
||||
await run;
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
|
||||
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import bolt from "@slack/bolt";
|
||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
@@ -699,34 +700,33 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
let blockSendChain: Promise<void> = Promise.resolve();
|
||||
const sendBlockReply = (payload: ReplyPayload) => {
|
||||
if (
|
||||
!payload?.text &&
|
||||
!payload?.mediaUrl &&
|
||||
!(payload?.mediaUrls?.length ?? 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
blockSendChain = blockSendChain
|
||||
.then(async () => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target: replyTarget,
|
||||
token: botToken,
|
||||
runtime,
|
||||
textLimit,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error?.(danger(`slack block reply failed: ${String(err)}`));
|
||||
const dispatcher = createReplyDispatcher({
|
||||
responsePrefix: cfg.messages?.responsePrefix,
|
||||
deliver: async (payload) => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target: replyTarget,
|
||||
token: botToken,
|
||||
runtime,
|
||||
textLimit,
|
||||
});
|
||||
};
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
danger(`slack ${info.kind} reply failed: ${String(err)}`),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const replyResult = await getReplyFromConfig(
|
||||
ctxPayload,
|
||||
{
|
||||
onBlockReply: sendBlockReply,
|
||||
onToolResult: (payload) => {
|
||||
dispatcher.sendToolResult(payload);
|
||||
},
|
||||
onBlockReply: (payload) => {
|
||||
dispatcher.sendBlockReply(payload);
|
||||
},
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
@@ -735,19 +735,16 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
? replyResult
|
||||
: [replyResult]
|
||||
: [];
|
||||
await blockSendChain;
|
||||
if (replies.length === 0) return;
|
||||
|
||||
await deliverReplies({
|
||||
replies,
|
||||
target: replyTarget,
|
||||
token: botToken,
|
||||
runtime,
|
||||
textLimit,
|
||||
});
|
||||
let queuedFinal = false;
|
||||
for (const reply of replies) {
|
||||
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||
}
|
||||
await dispatcher.waitForIdle();
|
||||
if (!queuedFinal) return;
|
||||
if (shouldLogVerbose()) {
|
||||
const finalCount = dispatcher.getQueuedCounts().final;
|
||||
logVerbose(
|
||||
`slack: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
`slack: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -228,6 +228,38 @@ describe("createTelegramBot", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("prefixes tool and final replies with responsePrefix", async () => {
|
||||
onSpy.mockReset();
|
||||
sendMessageSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
replySpy.mockImplementation(async (_ctx, opts) => {
|
||||
await opts?.onToolResult?.({ text: "tool result" });
|
||||
return { text: "final reply" };
|
||||
});
|
||||
loadConfig.mockReturnValue({ messages: { responsePrefix: "PFX" } });
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = onSpy.mock.calls[0][1] as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 5, type: "private" },
|
||||
text: "hi",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX tool result");
|
||||
expect(sendMessageSpy.mock.calls[1][1]).toBe("PFX final reply");
|
||||
});
|
||||
|
||||
it("honors replyToMode=all for threaded replies", async () => {
|
||||
onSpy.mockReset();
|
||||
sendMessageSpy.mockReset();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Bot, InputFile, webhookCallback } from "grammy";
|
||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { ReplyToMode } from "../config/config.js";
|
||||
@@ -228,37 +229,33 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
);
|
||||
}
|
||||
|
||||
let blockSendChain: Promise<void> = Promise.resolve();
|
||||
const sendBlockReply = (payload: ReplyPayload) => {
|
||||
if (
|
||||
!payload?.text &&
|
||||
!payload?.mediaUrl &&
|
||||
!(payload?.mediaUrls?.length ?? 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
blockSendChain = blockSendChain
|
||||
.then(async () => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
chatId: String(chatId),
|
||||
token: opts.token,
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error?.(
|
||||
danger(`telegram block reply failed: ${String(err)}`),
|
||||
);
|
||||
const dispatcher = createReplyDispatcher({
|
||||
responsePrefix: cfg.messages?.responsePrefix,
|
||||
deliver: async (payload) => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
chatId: String(chatId),
|
||||
token: opts.token,
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
};
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
danger(`telegram ${info.kind} reply failed: ${String(err)}`),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const replyResult = await getReplyFromConfig(
|
||||
ctxPayload,
|
||||
{ onReplyStart: sendTyping, onBlockReply: sendBlockReply },
|
||||
{
|
||||
onReplyStart: sendTyping,
|
||||
onToolResult: dispatcher.sendToolResult,
|
||||
onBlockReply: dispatcher.sendBlockReply,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
const replies = replyResult
|
||||
@@ -266,18 +263,12 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
? replyResult
|
||||
: [replyResult]
|
||||
: [];
|
||||
await blockSendChain;
|
||||
if (replies.length === 0) return;
|
||||
|
||||
await deliverReplies({
|
||||
replies,
|
||||
chatId: String(chatId),
|
||||
token: opts.token,
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
let queuedFinal = false;
|
||||
for (const reply of replies) {
|
||||
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||
}
|
||||
await dispatcher.waitForIdle();
|
||||
if (!queuedFinal) return;
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`handler failed: ${String(err)}`));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
HEARTBEAT_PROMPT,
|
||||
stripHeartbeatToken,
|
||||
} from "../auto-reply/heartbeat.js";
|
||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
@@ -222,14 +223,6 @@ function debugMention(
|
||||
|
||||
export { stripHeartbeatToken };
|
||||
|
||||
function isSilentReply(payload?: ReplyPayload): boolean {
|
||||
if (!payload) return false;
|
||||
const text = payload.text?.trim();
|
||||
if (!text || text !== SILENT_REPLY_TOKEN) return false;
|
||||
if (payload.mediaUrl || payload.mediaUrls?.length) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveHeartbeatReplyPayload(
|
||||
replyResult: ReplyPayload | ReplyPayload[] | undefined,
|
||||
): ReplyPayload | undefined {
|
||||
@@ -952,6 +945,25 @@ export async function monitorWebProvider(
|
||||
// Track recently sent messages to prevent echo loops
|
||||
const recentlySent = new Set<string>();
|
||||
const MAX_RECENT_MESSAGES = 100;
|
||||
const rememberSentText = (
|
||||
text: string | undefined,
|
||||
opts: { combinedBody: string; logVerboseMessage?: boolean },
|
||||
) => {
|
||||
if (!text) return;
|
||||
recentlySent.add(text);
|
||||
if (opts.combinedBody) {
|
||||
recentlySent.add(opts.combinedBody);
|
||||
}
|
||||
if (opts.logVerboseMessage) {
|
||||
logVerbose(
|
||||
`Added to echo detection set (size now: ${recentlySent.size}): ${text.substring(0, 50)}...`,
|
||||
);
|
||||
}
|
||||
if (recentlySent.size > MAX_RECENT_MESSAGES) {
|
||||
const firstKey = recentlySent.values().next().value;
|
||||
if (firstKey) recentlySent.delete(firstKey);
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
if (stopRequested()) break;
|
||||
@@ -1103,114 +1115,71 @@ export async function monitorWebProvider(
|
||||
}
|
||||
}
|
||||
|
||||
const responsePrefix = cfg.messages?.responsePrefix;
|
||||
const textLimit = resolveTextChunkLimit(cfg, "whatsapp");
|
||||
let didLogHeartbeatStrip = false;
|
||||
let didSendReply = false;
|
||||
let toolSendChain: Promise<void> = Promise.resolve();
|
||||
const sendToolResult = (payload: ReplyPayload) => {
|
||||
if (
|
||||
!payload?.text &&
|
||||
!payload?.mediaUrl &&
|
||||
!(payload?.mediaUrls?.length ?? 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isSilentReply(payload)) return;
|
||||
const toolPayload: ReplyPayload = { ...payload };
|
||||
if (toolPayload.text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(toolPayload.text, {
|
||||
mode: "message",
|
||||
});
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
const dispatcher = createReplyDispatcher({
|
||||
responsePrefix: cfg.messages?.responsePrefix,
|
||||
onHeartbeatStrip: () => {
|
||||
if (!didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
|
||||
}
|
||||
const hasMedia = Boolean(
|
||||
toolPayload.mediaUrl || (toolPayload.mediaUrls?.length ?? 0) > 0,
|
||||
},
|
||||
deliver: async (payload, info) => {
|
||||
await deliverWebReply({
|
||||
replyResult: payload,
|
||||
msg,
|
||||
maxMediaBytes,
|
||||
textLimit,
|
||||
replyLogger,
|
||||
connectionId,
|
||||
// Tool + block updates are noisy; skip their log lines.
|
||||
skipLog: info.kind !== "final",
|
||||
});
|
||||
didSendReply = true;
|
||||
if (info.kind === "tool") {
|
||||
rememberSentText(payload.text, { combinedBody: "" });
|
||||
return;
|
||||
}
|
||||
const shouldLog =
|
||||
info.kind === "final" && payload.text ? true : undefined;
|
||||
rememberSentText(payload.text, {
|
||||
combinedBody,
|
||||
logVerboseMessage: shouldLog,
|
||||
});
|
||||
if (info.kind === "final") {
|
||||
const fromDisplay =
|
||||
msg.chatType === "group"
|
||||
? conversationId
|
||||
: (msg.from ?? "unknown");
|
||||
const hasMedia = Boolean(
|
||||
payload.mediaUrl || payload.mediaUrls?.length,
|
||||
);
|
||||
whatsappOutboundLog.info(
|
||||
`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`,
|
||||
);
|
||||
if (shouldLogVerbose()) {
|
||||
const preview =
|
||||
payload.text != null ? elide(payload.text, 400) : "<media>";
|
||||
whatsappOutboundLog.debug(
|
||||
`Reply body: ${preview}${hasMedia ? " (media)" : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
const label =
|
||||
info.kind === "tool"
|
||||
? "tool update"
|
||||
: info.kind === "block"
|
||||
? "block update"
|
||||
: "auto-reply";
|
||||
whatsappOutboundLog.error(
|
||||
`Failed sending web ${label} to ${msg.from ?? conversationId}: ${formatError(err)}`,
|
||||
);
|
||||
if (stripped.shouldSkip && !hasMedia) return;
|
||||
toolPayload.text = stripped.text;
|
||||
}
|
||||
if (
|
||||
responsePrefix &&
|
||||
toolPayload.text &&
|
||||
toolPayload.text.trim() !== HEARTBEAT_TOKEN &&
|
||||
!toolPayload.text.startsWith(responsePrefix)
|
||||
) {
|
||||
toolPayload.text = `${responsePrefix} ${toolPayload.text}`;
|
||||
}
|
||||
toolSendChain = toolSendChain
|
||||
.then(async () => {
|
||||
await deliverWebReply({
|
||||
replyResult: toolPayload,
|
||||
msg,
|
||||
maxMediaBytes,
|
||||
textLimit,
|
||||
replyLogger,
|
||||
connectionId,
|
||||
skipLog: true,
|
||||
});
|
||||
didSendReply = true;
|
||||
if (toolPayload.text) {
|
||||
recentlySent.add(toolPayload.text);
|
||||
if (recentlySent.size > MAX_RECENT_MESSAGES) {
|
||||
const firstKey = recentlySent.values().next().value;
|
||||
if (firstKey) recentlySent.delete(firstKey);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
whatsappOutboundLog.error(
|
||||
`Failed sending web tool update to ${msg.from ?? conversationId}: ${formatError(err)}`,
|
||||
);
|
||||
});
|
||||
};
|
||||
const sendBlockReply = (payload: ReplyPayload) => {
|
||||
if (
|
||||
!payload?.text &&
|
||||
!payload?.mediaUrl &&
|
||||
!(payload?.mediaUrls?.length ?? 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isSilentReply(payload)) return;
|
||||
const blockPayload: ReplyPayload = { ...payload };
|
||||
if (
|
||||
responsePrefix &&
|
||||
blockPayload.text &&
|
||||
blockPayload.text.trim() !== HEARTBEAT_TOKEN &&
|
||||
!blockPayload.text.startsWith(responsePrefix)
|
||||
) {
|
||||
blockPayload.text = `${responsePrefix} ${blockPayload.text}`;
|
||||
}
|
||||
toolSendChain = toolSendChain
|
||||
.then(async () => {
|
||||
await deliverWebReply({
|
||||
replyResult: blockPayload,
|
||||
msg,
|
||||
maxMediaBytes,
|
||||
textLimit,
|
||||
replyLogger,
|
||||
connectionId,
|
||||
skipLog: true,
|
||||
});
|
||||
didSendReply = true;
|
||||
if (blockPayload.text) {
|
||||
recentlySent.add(blockPayload.text);
|
||||
recentlySent.add(combinedBody);
|
||||
if (recentlySent.size > MAX_RECENT_MESSAGES) {
|
||||
const firstKey = recentlySent.values().next().value;
|
||||
if (firstKey) recentlySent.delete(firstKey);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
whatsappOutboundLog.error(
|
||||
`Failed sending web block update to ${msg.from ?? conversationId}: ${formatError(err)}`,
|
||||
);
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||
{
|
||||
@@ -1238,8 +1207,12 @@ export async function monitorWebProvider(
|
||||
},
|
||||
{
|
||||
onReplyStart: msg.sendComposing,
|
||||
onToolResult: sendToolResult,
|
||||
onBlockReply: sendBlockReply,
|
||||
onToolResult: (payload) => {
|
||||
dispatcher.sendToolResult(payload);
|
||||
},
|
||||
onBlockReply: (payload) => {
|
||||
dispatcher.sendBlockReply(payload);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1249,12 +1222,12 @@ export async function monitorWebProvider(
|
||||
: [replyResult]
|
||||
: [];
|
||||
|
||||
const sendableReplies = replyList.filter(
|
||||
(payload) => !isSilentReply(payload),
|
||||
);
|
||||
|
||||
if (sendableReplies.length === 0) {
|
||||
await toolSendChain;
|
||||
let queuedFinal = false;
|
||||
for (const replyPayload of replyList) {
|
||||
queuedFinal = dispatcher.sendFinalReply(replyPayload) || queuedFinal;
|
||||
}
|
||||
await dispatcher.waitForIdle();
|
||||
if (!queuedFinal) {
|
||||
if (shouldClearGroupHistory && didSendReply) {
|
||||
groupHistories.set(conversationId, []);
|
||||
}
|
||||
@@ -1264,79 +1237,6 @@ export async function monitorWebProvider(
|
||||
return;
|
||||
}
|
||||
|
||||
await toolSendChain;
|
||||
|
||||
for (const replyPayload of sendableReplies) {
|
||||
if (replyPayload.text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(replyPayload.text, {
|
||||
mode: "message",
|
||||
});
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
|
||||
}
|
||||
const hasMedia = Boolean(
|
||||
replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
if (stripped.shouldSkip && !hasMedia) continue;
|
||||
replyPayload.text = stripped.text;
|
||||
}
|
||||
if (
|
||||
responsePrefix &&
|
||||
replyPayload.text &&
|
||||
replyPayload.text.trim() !== HEARTBEAT_TOKEN &&
|
||||
!replyPayload.text.startsWith(responsePrefix)
|
||||
) {
|
||||
replyPayload.text = `${responsePrefix} ${replyPayload.text}`;
|
||||
}
|
||||
|
||||
try {
|
||||
await deliverWebReply({
|
||||
replyResult: replyPayload,
|
||||
msg,
|
||||
maxMediaBytes,
|
||||
textLimit,
|
||||
replyLogger,
|
||||
connectionId,
|
||||
});
|
||||
didSendReply = true;
|
||||
|
||||
if (replyPayload.text) {
|
||||
recentlySent.add(replyPayload.text);
|
||||
recentlySent.add(combinedBody); // Prevent echo on the combined text itself
|
||||
logVerbose(
|
||||
`Added to echo detection set (size now: ${recentlySent.size}): ${replyPayload.text.substring(0, 50)}...`,
|
||||
);
|
||||
if (recentlySent.size > MAX_RECENT_MESSAGES) {
|
||||
const firstKey = recentlySent.values().next().value;
|
||||
if (firstKey) recentlySent.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
const fromDisplay =
|
||||
msg.chatType === "group" ? conversationId : (msg.from ?? "unknown");
|
||||
const hasMedia = Boolean(
|
||||
replyPayload.mediaUrl || replyPayload.mediaUrls?.length,
|
||||
);
|
||||
whatsappOutboundLog.info(
|
||||
`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`,
|
||||
);
|
||||
if (shouldLogVerbose()) {
|
||||
const preview =
|
||||
replyPayload.text != null
|
||||
? elide(replyPayload.text, 400)
|
||||
: "<media>";
|
||||
whatsappOutboundLog.debug(
|
||||
`Reply body: ${preview}${hasMedia ? " (media)" : ""}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
whatsappOutboundLog.error(
|
||||
`Failed sending web auto-reply to ${msg.from ?? conversationId}: ${formatError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldClearGroupHistory && didSendReply) {
|
||||
groupHistories.set(conversationId, []);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user