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: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
|
||||||
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
|
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
|
||||||
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
|
- 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
|
### Maintenance
|
||||||
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
- 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>
|
</p>
|
||||||
|
|
||||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
**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.
|
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**.
|
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)
|
## Recommended setup (from source)
|
||||||
|
|
||||||
@@ -41,97 +41,6 @@ pnpm ui:build
|
|||||||
pnpm clawdbot onboard
|
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)
|
## Quick start (from source)
|
||||||
|
|
||||||
Runtime: **Node ≥22** + **pnpm**.
|
Runtime: **Node ≥22** + **pnpm**.
|
||||||
@@ -156,15 +65,81 @@ pnpm gateway:watch
|
|||||||
# Send a message
|
# Send a message
|
||||||
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
|
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
|
pnpm clawdbot agent --message "Ship checklist" --thinking high
|
||||||
```
|
```
|
||||||
|
|
||||||
If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
|
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
|
## 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)
|
- `/status` — health + session info (group shows activation mode)
|
||||||
- `/new` or `/reset` — reset the session
|
- `/new` or `/reset` — reset the session
|
||||||
@@ -209,13 +184,13 @@ Build/run: `./scripts/restart-mac.sh` (packages + launches).
|
|||||||
- Voice trigger forwarding + Canvas surface.
|
- Voice trigger forwarding + Canvas surface.
|
||||||
- Controlled via `clawdbot nodes …`.
|
- Controlled via `clawdbot nodes …`.
|
||||||
|
|
||||||
Runbook: `docs/ios/connect.md`.
|
Runbook: [iOS connect](docs/ios/connect.md).
|
||||||
|
|
||||||
### Android node (internal)
|
### Android node (internal)
|
||||||
|
|
||||||
- Pairs via the same Bridge + pairing flow as iOS.
|
- Pairs via the same Bridge + pairing flow as iOS.
|
||||||
- Exposes Canvas, Camera, and Screen capture commands.
|
- Exposes Canvas, Camera, and Screen capture commands.
|
||||||
- Runbook: `docs/android/connect.md`.
|
- Runbook: [Android connect](docs/android/connect.md).
|
||||||
|
|
||||||
## Agent workspace + skills
|
## 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`
|
### `talk`
|
||||||
|
|
||||||
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.
|
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
|
normal (non-heartbeat) reply, Clawdbot strips the token and logs a verbose
|
||||||
message. If the reply is only `HEARTBEAT_OK`, it is dropped.
|
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
|
## Config
|
||||||
|
|
||||||
```json5
|
```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 { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.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 { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -532,39 +533,36 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let didSendReply = false;
|
let didSendReply = false;
|
||||||
let blockSendChain: Promise<void> = Promise.resolve();
|
const dispatcher = createReplyDispatcher({
|
||||||
const sendBlockReply = (payload: ReplyPayload) => {
|
responsePrefix: cfg.messages?.responsePrefix,
|
||||||
if (
|
deliver: async (payload) => {
|
||||||
!payload?.text &&
|
await deliverReplies({
|
||||||
!payload?.mediaUrl &&
|
replies: [payload],
|
||||||
!(payload?.mediaUrls?.length ?? 0)
|
target: replyTarget,
|
||||||
) {
|
token,
|
||||||
return;
|
runtime,
|
||||||
}
|
replyToMode,
|
||||||
blockSendChain = blockSendChain
|
textLimit,
|
||||||
.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)}`),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
didSendReply = true;
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(
|
||||||
|
danger(`discord ${info.kind} reply failed: ${String(err)}`),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const replyResult = await getReplyFromConfig(
|
const replyResult = await getReplyFromConfig(
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
{
|
{
|
||||||
onReplyStart: () => sendTyping(message),
|
onReplyStart: () => sendTyping(message),
|
||||||
onBlockReply: sendBlockReply,
|
onToolResult: (payload) => {
|
||||||
|
dispatcher.sendToolResult(payload);
|
||||||
|
},
|
||||||
|
onBlockReply: (payload) => {
|
||||||
|
dispatcher.sendBlockReply(payload);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
@@ -573,8 +571,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
? replyResult
|
? replyResult
|
||||||
: [replyResult]
|
: [replyResult]
|
||||||
: [];
|
: [];
|
||||||
await blockSendChain;
|
let queuedFinal = false;
|
||||||
if (replies.length === 0) {
|
for (const reply of replies) {
|
||||||
|
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||||
|
}
|
||||||
|
await dispatcher.waitForIdle();
|
||||||
|
if (!queuedFinal) {
|
||||||
if (
|
if (
|
||||||
isGuildMessage &&
|
isGuildMessage &&
|
||||||
shouldClearHistory &&
|
shouldClearHistory &&
|
||||||
@@ -585,19 +587,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deliverReplies({
|
|
||||||
replies,
|
|
||||||
target: replyTarget,
|
|
||||||
token,
|
|
||||||
runtime,
|
|
||||||
replyToMode,
|
|
||||||
textLimit,
|
|
||||||
});
|
|
||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
if (shouldLogVerbose()) {
|
if (shouldLogVerbose()) {
|
||||||
|
const finalCount = dispatcher.getQueuedCounts().final;
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`discord: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${replyTarget}`,
|
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -139,6 +139,41 @@ describe("monitorIMessageProvider", () => {
|
|||||||
expect(replyMock).toHaveBeenCalled();
|
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 () => {
|
it("delivers group replies when mentioned", async () => {
|
||||||
replyMock.mockResolvedValueOnce({ text: "yo" });
|
replyMock.mockResolvedValueOnce({ text: "yo" });
|
||||||
const run = monitorIMessageProvider();
|
const run = monitorIMessageProvider();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.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 { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
@@ -267,36 +268,35 @@ export async function monitorIMessageProvider(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let blockSendChain: Promise<void> = Promise.resolve();
|
const dispatcher = createReplyDispatcher({
|
||||||
const sendBlockReply = (payload: ReplyPayload) => {
|
responsePrefix: cfg.messages?.responsePrefix,
|
||||||
if (
|
deliver: async (payload) => {
|
||||||
!payload?.text &&
|
await deliverReplies({
|
||||||
!payload?.mediaUrl &&
|
replies: [payload],
|
||||||
!(payload?.mediaUrls?.length ?? 0)
|
target: ctxPayload.To,
|
||||||
) {
|
client,
|
||||||
return;
|
runtime,
|
||||||
}
|
maxBytes: mediaMaxBytes,
|
||||||
blockSendChain = blockSendChain
|
textLimit,
|
||||||
.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)}`),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(
|
||||||
|
danger(`imessage ${info.kind} reply failed: ${String(err)}`),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const replyResult = await getReplyFromConfig(
|
const replyResult = await getReplyFromConfig(
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
{ onBlockReply: sendBlockReply },
|
{
|
||||||
|
onToolResult: (payload) => {
|
||||||
|
dispatcher.sendToolResult(payload);
|
||||||
|
},
|
||||||
|
onBlockReply: (payload) => {
|
||||||
|
dispatcher.sendBlockReply(payload);
|
||||||
|
},
|
||||||
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
const replies = replyResult
|
const replies = replyResult
|
||||||
@@ -304,17 +304,12 @@ export async function monitorIMessageProvider(
|
|||||||
? replyResult
|
? replyResult
|
||||||
: [replyResult]
|
: [replyResult]
|
||||||
: [];
|
: [];
|
||||||
await blockSendChain;
|
let queuedFinal = false;
|
||||||
if (replies.length === 0) return;
|
for (const reply of replies) {
|
||||||
|
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||||
await deliverReplies({
|
}
|
||||||
replies,
|
await dispatcher.waitForIdle();
|
||||||
target: ctxPayload.To,
|
if (!queuedFinal) return;
|
||||||
client,
|
|
||||||
runtime,
|
|
||||||
maxBytes: mediaMaxBytes,
|
|
||||||
textLimit,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = await createIMessageRpcClient({
|
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 { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.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 { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
@@ -379,37 +380,36 @@ export async function monitorSignalProvider(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let blockSendChain: Promise<void> = Promise.resolve();
|
const dispatcher = createReplyDispatcher({
|
||||||
const sendBlockReply = (payload: ReplyPayload) => {
|
responsePrefix: cfg.messages?.responsePrefix,
|
||||||
if (
|
deliver: async (payload) => {
|
||||||
!payload?.text &&
|
await deliverReplies({
|
||||||
!payload?.mediaUrl &&
|
replies: [payload],
|
||||||
!(payload?.mediaUrls?.length ?? 0)
|
target: ctxPayload.To,
|
||||||
) {
|
baseUrl,
|
||||||
return;
|
account,
|
||||||
}
|
runtime,
|
||||||
blockSendChain = blockSendChain
|
maxBytes: mediaMaxBytes,
|
||||||
.then(async () => {
|
textLimit,
|
||||||
await deliverReplies({
|
|
||||||
replies: [payload],
|
|
||||||
target: ctxPayload.To,
|
|
||||||
baseUrl,
|
|
||||||
account,
|
|
||||||
runtime,
|
|
||||||
maxBytes: mediaMaxBytes,
|
|
||||||
textLimit,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
runtime.error?.(
|
|
||||||
danger(`signal block reply failed: ${String(err)}`),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(
|
||||||
|
danger(`signal ${info.kind} reply failed: ${String(err)}`),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const replyResult = await getReplyFromConfig(
|
const replyResult = await getReplyFromConfig(
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
{ onBlockReply: sendBlockReply },
|
{
|
||||||
|
onToolResult: (payload) => {
|
||||||
|
dispatcher.sendToolResult(payload);
|
||||||
|
},
|
||||||
|
onBlockReply: (payload) => {
|
||||||
|
dispatcher.sendBlockReply(payload);
|
||||||
|
},
|
||||||
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
const replies = replyResult
|
const replies = replyResult
|
||||||
@@ -417,18 +417,12 @@ export async function monitorSignalProvider(
|
|||||||
? replyResult
|
? replyResult
|
||||||
: [replyResult]
|
: [replyResult]
|
||||||
: [];
|
: [];
|
||||||
await blockSendChain;
|
let queuedFinal = false;
|
||||||
if (replies.length === 0) return;
|
for (const reply of replies) {
|
||||||
|
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||||
await deliverReplies({
|
}
|
||||||
replies,
|
await dispatcher.waitForIdle();
|
||||||
target: ctxPayload.To,
|
if (!queuedFinal) return;
|
||||||
baseUrl,
|
|
||||||
account,
|
|
||||||
runtime,
|
|
||||||
maxBytes: mediaMaxBytes,
|
|
||||||
textLimit,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await streamSignalEvents({
|
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 { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.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 { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.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 dispatcher = createReplyDispatcher({
|
||||||
const sendBlockReply = (payload: ReplyPayload) => {
|
responsePrefix: cfg.messages?.responsePrefix,
|
||||||
if (
|
deliver: async (payload) => {
|
||||||
!payload?.text &&
|
await deliverReplies({
|
||||||
!payload?.mediaUrl &&
|
replies: [payload],
|
||||||
!(payload?.mediaUrls?.length ?? 0)
|
target: replyTarget,
|
||||||
) {
|
token: botToken,
|
||||||
return;
|
runtime,
|
||||||
}
|
textLimit,
|
||||||
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)}`));
|
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(
|
||||||
|
danger(`slack ${info.kind} reply failed: ${String(err)}`),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const replyResult = await getReplyFromConfig(
|
const replyResult = await getReplyFromConfig(
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
{
|
{
|
||||||
onBlockReply: sendBlockReply,
|
onToolResult: (payload) => {
|
||||||
|
dispatcher.sendToolResult(payload);
|
||||||
|
},
|
||||||
|
onBlockReply: (payload) => {
|
||||||
|
dispatcher.sendBlockReply(payload);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
@@ -735,19 +735,16 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
? replyResult
|
? replyResult
|
||||||
: [replyResult]
|
: [replyResult]
|
||||||
: [];
|
: [];
|
||||||
await blockSendChain;
|
let queuedFinal = false;
|
||||||
if (replies.length === 0) return;
|
for (const reply of replies) {
|
||||||
|
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||||
await deliverReplies({
|
}
|
||||||
replies,
|
await dispatcher.waitForIdle();
|
||||||
target: replyTarget,
|
if (!queuedFinal) return;
|
||||||
token: botToken,
|
|
||||||
runtime,
|
|
||||||
textLimit,
|
|
||||||
});
|
|
||||||
if (shouldLogVerbose()) {
|
if (shouldLogVerbose()) {
|
||||||
|
const finalCount = dispatcher.getQueuedCounts().final;
|
||||||
logVerbose(
|
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 () => {
|
it("honors replyToMode=all for threaded replies", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
sendMessageSpy.mockReset();
|
sendMessageSpy.mockReset();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Bot, InputFile, webhookCallback } from "grammy";
|
|||||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.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 { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import type { ReplyToMode } from "../config/config.js";
|
import type { ReplyToMode } from "../config/config.js";
|
||||||
@@ -228,37 +229,33 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let blockSendChain: Promise<void> = Promise.resolve();
|
const dispatcher = createReplyDispatcher({
|
||||||
const sendBlockReply = (payload: ReplyPayload) => {
|
responsePrefix: cfg.messages?.responsePrefix,
|
||||||
if (
|
deliver: async (payload) => {
|
||||||
!payload?.text &&
|
await deliverReplies({
|
||||||
!payload?.mediaUrl &&
|
replies: [payload],
|
||||||
!(payload?.mediaUrls?.length ?? 0)
|
chatId: String(chatId),
|
||||||
) {
|
token: opts.token,
|
||||||
return;
|
runtime,
|
||||||
}
|
bot,
|
||||||
blockSendChain = blockSendChain
|
replyToMode,
|
||||||
.then(async () => {
|
textLimit,
|
||||||
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)}`),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(
|
||||||
|
danger(`telegram ${info.kind} reply failed: ${String(err)}`),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const replyResult = await getReplyFromConfig(
|
const replyResult = await getReplyFromConfig(
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
{ onReplyStart: sendTyping, onBlockReply: sendBlockReply },
|
{
|
||||||
|
onReplyStart: sendTyping,
|
||||||
|
onToolResult: dispatcher.sendToolResult,
|
||||||
|
onBlockReply: dispatcher.sendBlockReply,
|
||||||
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
const replies = replyResult
|
const replies = replyResult
|
||||||
@@ -266,18 +263,12 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
? replyResult
|
? replyResult
|
||||||
: [replyResult]
|
: [replyResult]
|
||||||
: [];
|
: [];
|
||||||
await blockSendChain;
|
let queuedFinal = false;
|
||||||
if (replies.length === 0) return;
|
for (const reply of replies) {
|
||||||
|
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||||
await deliverReplies({
|
}
|
||||||
replies,
|
await dispatcher.waitForIdle();
|
||||||
chatId: String(chatId),
|
if (!queuedFinal) return;
|
||||||
token: opts.token,
|
|
||||||
runtime,
|
|
||||||
bot,
|
|
||||||
replyToMode,
|
|
||||||
textLimit,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(danger(`handler failed: ${String(err)}`));
|
runtime.error?.(danger(`handler failed: ${String(err)}`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
HEARTBEAT_PROMPT,
|
HEARTBEAT_PROMPT,
|
||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
} from "../auto-reply/heartbeat.js";
|
} from "../auto-reply/heartbeat.js";
|
||||||
|
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
@@ -222,14 +223,6 @@ function debugMention(
|
|||||||
|
|
||||||
export { stripHeartbeatToken };
|
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(
|
function resolveHeartbeatReplyPayload(
|
||||||
replyResult: ReplyPayload | ReplyPayload[] | undefined,
|
replyResult: ReplyPayload | ReplyPayload[] | undefined,
|
||||||
): ReplyPayload | undefined {
|
): ReplyPayload | undefined {
|
||||||
@@ -952,6 +945,25 @@ export async function monitorWebProvider(
|
|||||||
// Track recently sent messages to prevent echo loops
|
// Track recently sent messages to prevent echo loops
|
||||||
const recentlySent = new Set<string>();
|
const recentlySent = new Set<string>();
|
||||||
const MAX_RECENT_MESSAGES = 100;
|
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) {
|
while (true) {
|
||||||
if (stopRequested()) break;
|
if (stopRequested()) break;
|
||||||
@@ -1103,114 +1115,71 @@ export async function monitorWebProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsePrefix = cfg.messages?.responsePrefix;
|
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "whatsapp");
|
const textLimit = resolveTextChunkLimit(cfg, "whatsapp");
|
||||||
let didLogHeartbeatStrip = false;
|
let didLogHeartbeatStrip = false;
|
||||||
let didSendReply = false;
|
let didSendReply = false;
|
||||||
let toolSendChain: Promise<void> = Promise.resolve();
|
const dispatcher = createReplyDispatcher({
|
||||||
const sendToolResult = (payload: ReplyPayload) => {
|
responsePrefix: cfg.messages?.responsePrefix,
|
||||||
if (
|
onHeartbeatStrip: () => {
|
||||||
!payload?.text &&
|
if (!didLogHeartbeatStrip) {
|
||||||
!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) {
|
|
||||||
didLogHeartbeatStrip = true;
|
didLogHeartbeatStrip = true;
|
||||||
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
|
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)(
|
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||||
{
|
{
|
||||||
@@ -1238,8 +1207,12 @@ export async function monitorWebProvider(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onReplyStart: msg.sendComposing,
|
onReplyStart: msg.sendComposing,
|
||||||
onToolResult: sendToolResult,
|
onToolResult: (payload) => {
|
||||||
onBlockReply: sendBlockReply,
|
dispatcher.sendToolResult(payload);
|
||||||
|
},
|
||||||
|
onBlockReply: (payload) => {
|
||||||
|
dispatcher.sendBlockReply(payload);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1249,12 +1222,12 @@ export async function monitorWebProvider(
|
|||||||
: [replyResult]
|
: [replyResult]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const sendableReplies = replyList.filter(
|
let queuedFinal = false;
|
||||||
(payload) => !isSilentReply(payload),
|
for (const replyPayload of replyList) {
|
||||||
);
|
queuedFinal = dispatcher.sendFinalReply(replyPayload) || queuedFinal;
|
||||||
|
}
|
||||||
if (sendableReplies.length === 0) {
|
await dispatcher.waitForIdle();
|
||||||
await toolSendChain;
|
if (!queuedFinal) {
|
||||||
if (shouldClearGroupHistory && didSendReply) {
|
if (shouldClearGroupHistory && didSendReply) {
|
||||||
groupHistories.set(conversationId, []);
|
groupHistories.set(conversationId, []);
|
||||||
}
|
}
|
||||||
@@ -1264,79 +1237,6 @@ export async function monitorWebProvider(
|
|||||||
return;
|
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) {
|
if (shouldClearGroupHistory && didSendReply) {
|
||||||
groupHistories.set(conversationId, []);
|
groupHistories.set(conversationId, []);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user