refactor: unify reply dispatch across providers
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
|
||||
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
|
||||
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
|
||||
- Auto-reply: unify tool/block/final delivery across providers and apply consistent heartbeat/prefix handling. Thanks @MSch for PR #225 (superseded commit 92c953d0749143eb2a3f31f3cd6ad0e8eabf48c3).
|
||||
|
||||
### Maintenance
|
||||
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
||||
|
||||
171
README.md
171
README.md
@@ -16,15 +16,15 @@
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Slack, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
Website: https://clawd.me · Docs: https://docs.clawdbot.com/ · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Nix: [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
|
||||
Website: [clawdbot.com](https://clawdbot.com) · Docs: [docs.clawdbot.com](https://docs.clawdbot.com/) · FAQ: [FAQ](docs/faq.md) · Wizard: [Wizard](docs/wizard.md) · Nix: [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [Docker](docs/docker.md) · Discord: [discord.gg/clawd](https://discord.gg/clawd)
|
||||
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
||||
|
||||
Subscriptions: **Anthropic (Claude Pro/Max)** and **OpenAI (ChatGPT/Codex)** are supported via OAuth. See `docs/onboarding.md`.
|
||||
Subscriptions: **Anthropic (Claude Pro/Max)** and **OpenAI (ChatGPT/Codex)** are supported via OAuth. See [Onboarding](docs/onboarding.md).
|
||||
|
||||
## Recommended setup (from source)
|
||||
|
||||
@@ -41,97 +41,6 @@ pnpm ui:build
|
||||
pnpm clawdbot onboard
|
||||
```
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Local-first Gateway** — single control plane for sessions, providers, tools, and events.
|
||||
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **Voice Wake + Talk Mode** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **Live Canvas** — agent-driven visual workspace with A2UI.
|
||||
- **First-class tools** — browser, canvas, nodes, cron, sessions, and Discord actions.
|
||||
- **Companion apps** — macOS menu bar app + iOS/Android nodes.
|
||||
- **Onboarding + skills** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
- Gateway WS control plane with sessions, presence, config, cron, webhooks, control UI, and Canvas host.
|
||||
- CLI surface: gateway, agent, send, wizard, doctor/update, and TUI.
|
||||
- Pi agent runtime in RPC mode with tool streaming and block streaming.
|
||||
- Session model: `main` for direct chats, group isolation, activation modes, queue modes, reply-back.
|
||||
- Media pipeline: images/audio/video, transcription hooks, size caps, temp file lifecycle.
|
||||
|
||||
### Surfaces + providers
|
||||
- WhatsApp (Baileys), Telegram (grammY), Discord (discord.js), Signal (signal-cli), iMessage (imsg), WebChat.
|
||||
- Group mention gating, reply tags, per-surface chunking and routing.
|
||||
|
||||
### Apps + nodes
|
||||
- macOS app: menu bar control plane, Voice Wake/PTT, Talk Mode overlay, WebChat, Debug tools, SSH remote gateway control.
|
||||
- iOS node: Canvas, Voice Wake, Talk Mode, camera, screen recording, Bonjour pairing.
|
||||
- Android node: Canvas, Talk Mode, camera, screen recording, optional SMS.
|
||||
- macOS node mode: system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
- Browser control: dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- Canvas: A2UI push/reset, eval, snapshot.
|
||||
- Nodes: camera snap/clip, screen record, location.get, notifications.
|
||||
- Cron + wakeups; webhooks; Gmail Pub/Sub triggers.
|
||||
- Skills platform: bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Ops + packaging
|
||||
- Control UI + WebChat served directly from the Gateway.
|
||||
- Tailscale Serve/Funnel or SSH tunnels with token/password auth.
|
||||
- Nix mode for declarative config; Docker-based installs.
|
||||
- Health, doctor migrations, structured logging, release tooling.
|
||||
|
||||
## Changes since 2026.1.4 (2026-01-04)
|
||||
|
||||
### Highlights
|
||||
- Project rename completed: CLIs, paths, bundle IDs, env vars, and docs unified on Clawdbot.
|
||||
- Agent-to-agent relay: `sessions_send` ping‑pong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
|
||||
- Gateway config hot reload, configurable port, and Control UI base-path support.
|
||||
- Sandbox options: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
|
||||
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
|
||||
|
||||
### Fixes
|
||||
- Presence beacons keep node lists fresh; Instances view stays accurate.
|
||||
- Block streaming + chunking reliability (Telegram/Discord ordering, fewer duplicates).
|
||||
- WhatsApp GIF playback for MP4-based GIFs.
|
||||
- Onboarding/Control UI basePath handling fixes + UI polish.
|
||||
- Cleaner logging + clearer tool summaries.
|
||||
|
||||
### Breaking
|
||||
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
||||
- Bash tool removed `stdinMode: "pty"` support (use tmux for real TTYs).
|
||||
- Primary session key is fixed to `main` (or `global` for global scope).
|
||||
|
||||
## Project rename + changelog format
|
||||
|
||||
Clawdis → Clawdbot. The rename touched every surface, path, and bundle ID. To make that transition explicit, releases now use **date-based versions** (`YYYY.M.D`), and the changelog is compressed into milestone summaries instead of long semver trains. Full detail still lives in git history and the docs.
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
Your surfaces
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789
|
||||
│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ WebChat (browser)
|
||||
├─ macOS app (Clawdbot.app)
|
||||
└─ iOS node (Canvas + voice)
|
||||
```
|
||||
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
https://clawdhub.com
|
||||
|
||||
## Quick start (from source)
|
||||
|
||||
Runtime: **Node ≥22** + **pnpm**.
|
||||
@@ -156,15 +65,81 @@ pnpm gateway:watch
|
||||
# Send a message
|
||||
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
|
||||
pnpm clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Local-first Gateway** — single control plane for sessions, providers, tools, and events.
|
||||
- **Multi-surface inbox** — WhatsApp, Telegram, Slack, Discord, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **Voice Wake + Talk Mode** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **Live Canvas** — agent-driven visual workspace with A2UI.
|
||||
- **First-class tools** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **Companion apps** — macOS menu bar app + iOS/Android nodes.
|
||||
- **Onboarding + skills** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
- Gateway WS control plane with sessions, presence, config, cron, webhooks, control UI, and Canvas host.
|
||||
- CLI surface: gateway, agent, send, wizard, doctor/update, and TUI.
|
||||
- Pi agent runtime in RPC mode with tool streaming and block streaming.
|
||||
- Session model: `main` for direct chats, group isolation, activation modes, queue modes, reply-back.
|
||||
- Media pipeline: images/audio/video, transcription hooks, size caps, temp file lifecycle.
|
||||
|
||||
### Surfaces + providers
|
||||
- WhatsApp (Baileys), Telegram (grammY), Slack (Bolt), Discord (discord.js), Signal (signal-cli), iMessage (imsg), WebChat.
|
||||
- Group mention gating, reply tags, per-surface chunking and routing.
|
||||
|
||||
### Apps + nodes
|
||||
- macOS app: menu bar control plane, Voice Wake/PTT, Talk Mode overlay, WebChat, Debug tools, SSH remote gateway control.
|
||||
- iOS node: Canvas, Voice Wake, Talk Mode, camera, screen recording, Bonjour pairing.
|
||||
- Android node: Canvas, Talk Mode, camera, screen recording, optional SMS.
|
||||
- macOS node mode: system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
- Browser control: dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- Canvas: A2UI push/reset, eval, snapshot.
|
||||
- Nodes: camera snap/clip, screen record, location.get, notifications.
|
||||
- Cron + wakeups; webhooks; Gmail Pub/Sub triggers.
|
||||
- Skills platform: bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Ops + packaging
|
||||
- Control UI + WebChat served directly from the Gateway.
|
||||
- Tailscale Serve/Funnel or SSH tunnels with token/password auth.
|
||||
- Nix mode for declarative config; Docker-based installs.
|
||||
- Health, doctor migrations, structured logging, release tooling.
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
Your surfaces
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789
|
||||
│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ WebChat (browser)
|
||||
├─ macOS app (Clawdbot.app)
|
||||
└─ iOS node (Canvas + voice)
|
||||
```
|
||||
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
https://clawdhub.com
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
|
||||
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — health + session info (group shows activation mode)
|
||||
- `/new` or `/reset` — reset the session
|
||||
@@ -209,13 +184,13 @@ Build/run: `./scripts/restart-mac.sh` (packages + launches).
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `clawdbot nodes …`.
|
||||
|
||||
Runbook: `docs/ios/connect.md`.
|
||||
Runbook: [iOS connect](docs/ios/connect.md).
|
||||
|
||||
### Android node (internal)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: `docs/android/connect.md`.
|
||||
- Runbook: [Android connect](docs/android/connect.md).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
|
||||
@@ -446,6 +446,9 @@ Controls inbound/outbound prefixes and timestamps.
|
||||
}
|
||||
```
|
||||
|
||||
`responsePrefix` is applied to **all outbound replies** (tool summaries, block
|
||||
streaming, final replies) across providers unless already present.
|
||||
|
||||
### `talk`
|
||||
|
||||
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.
|
||||
|
||||
@@ -22,6 +22,13 @@ If the model accidentally includes `HEARTBEAT_OK` at the start or end of a
|
||||
normal (non-heartbeat) reply, Clawdbot strips the token and logs a verbose
|
||||
message. If the reply is only `HEARTBEAT_OK`, it is dropped.
|
||||
|
||||
### Outbound normalization (all providers)
|
||||
For **all providers** (WhatsApp/Web, Telegram, Slack, Discord, Signal, iMessage),
|
||||
Clawdbot applies the same filtering to tool summaries, streaming block replies,
|
||||
and final replies:
|
||||
- drop payloads that are only `HEARTBEAT_OK` with no media
|
||||
- strip `HEARTBEAT_OK` at the edges when mixed with other text
|
||||
|
||||
## Config
|
||||
|
||||
```json5
|
||||
|
||||
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