Skip to content

Commit 13eb9c9

Browse files
committed
refactor: centralize reply dispatch
1 parent 319dd14 commit 13eb9c9

10 files changed

Lines changed: 90 additions & 131 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
3131
- Group related changes; avoid bundling unrelated refactors.
3232
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
33+
- When working on a PR: add a changelog entry with the PR ID and thank the contributor.
34+
- When working on an issue: reference the issue in the changelog entry.
35+
- When merging a PR: leave a PR comment that explains exactly what we did.
3336

3437
## Security & Configuration Tips
3538
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
4747
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
4848
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
49-
- Auto-reply: unify tool/block/final delivery across providers and apply consistent heartbeat/prefix handling. Thanks @MSch for PR #225 (superseded commit 92c953d0749143eb2a3f31f3cd6ad0e8eabf48c3).
49+
- Auto-reply: centralize tool/block/final dispatch across providers for consistent streaming + heartbeat/prefix handling. Thanks @MSch for PR #225.
5050
- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman
5151
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
5252
- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { ClawdbotConfig } from "../../config/config.js";
2+
import { getReplyFromConfig } from "../reply.js";
3+
import type { MsgContext } from "../templating.js";
4+
import type { GetReplyOptions, ReplyPayload } from "../types.js";
5+
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
6+
7+
type DispatchFromConfigResult = {
8+
queuedFinal: boolean;
9+
counts: Record<ReplyDispatchKind, number>;
10+
};
11+
12+
export async function dispatchReplyFromConfig(params: {
13+
ctx: MsgContext;
14+
cfg: ClawdbotConfig;
15+
dispatcher: ReplyDispatcher;
16+
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
17+
replyResolver?: typeof getReplyFromConfig;
18+
}): Promise<DispatchFromConfigResult> {
19+
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
20+
params.ctx,
21+
{
22+
...params.replyOptions,
23+
onToolResult: (payload: ReplyPayload) => {
24+
params.dispatcher.sendToolResult(payload);
25+
},
26+
onBlockReply: (payload: ReplyPayload) => {
27+
params.dispatcher.sendBlockReply(payload);
28+
},
29+
},
30+
params.cfg,
31+
);
32+
33+
const replies = replyResult
34+
? Array.isArray(replyResult)
35+
? replyResult
36+
: [replyResult]
37+
: [];
38+
39+
let queuedFinal = false;
40+
for (const reply of replies) {
41+
queuedFinal = params.dispatcher.sendFinalReply(reply) || queuedFinal;
42+
}
43+
await params.dispatcher.waitForIdle();
44+
45+
return { queuedFinal, counts: params.dispatcher.getQueuedCounts() };
46+
}

src/auto-reply/reply/reply-dispatcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export type ReplyDispatcherOptions = {
2222
onError?: ReplyDispatchErrorHandler;
2323
};
2424

25-
type ReplyDispatcher = {
25+
export type ReplyDispatcher = {
2626
sendToolResult: (payload: ReplyPayload) => boolean;
2727
sendBlockReply: (payload: ReplyPayload) => boolean;
2828
sendFinalReply: (payload: ReplyPayload) => boolean;

src/discord/monitor.ts

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ import {
1818
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
1919
import { hasControlCommand } from "../auto-reply/command-detection.js";
2020
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
21+
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
2122
import {
2223
buildMentionRegexes,
2324
matchesMentionPatterns,
2425
} from "../auto-reply/reply/mentions.js";
2526
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
2627
import type { TypingController } from "../auto-reply/reply/typing.js";
27-
import { getReplyFromConfig } from "../auto-reply/reply.js";
2828
import type { ReplyPayload } from "../auto-reply/types.js";
2929
import type {
3030
DiscordSlashCommandConfig,
@@ -589,32 +589,17 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
589589
},
590590
});
591591

592-
const replyResult = await getReplyFromConfig(
593-
ctxPayload,
594-
{
592+
const { queuedFinal, counts } = await dispatchReplyFromConfig({
593+
ctx: ctxPayload,
594+
cfg,
595+
dispatcher,
596+
replyOptions: {
595597
onReplyStart: () => sendTyping(message),
596598
onTypingController: (typing) => {
597599
typingController = typing;
598600
},
599-
onToolResult: (payload) => {
600-
dispatcher.sendToolResult(payload);
601-
},
602-
onBlockReply: (payload) => {
603-
dispatcher.sendBlockReply(payload);
604-
},
605601
},
606-
cfg,
607-
);
608-
const replies = replyResult
609-
? Array.isArray(replyResult)
610-
? replyResult
611-
: [replyResult]
612-
: [];
613-
let queuedFinal = false;
614-
for (const reply of replies) {
615-
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
616-
}
617-
await dispatcher.waitForIdle();
602+
});
618603
typingController?.markDispatchIdle();
619604
if (!queuedFinal) {
620605
if (
@@ -629,7 +614,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
629614
}
630615
didSendReply = true;
631616
if (shouldLogVerbose()) {
632-
const finalCount = dispatcher.getQueuedCounts().final;
617+
const finalCount = counts.final;
633618
logVerbose(
634619
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
635620
);

src/imessage/monitor.ts

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
22
import { hasControlCommand } from "../auto-reply/command-detection.js";
33
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
4+
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
45
import {
56
buildMentionRegexes,
67
matchesMentionPatterns,
78
} from "../auto-reply/reply/mentions.js";
89
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
9-
import { getReplyFromConfig } from "../auto-reply/reply.js";
1010
import type { ReplyPayload } from "../auto-reply/types.js";
1111
import { loadConfig } from "../config/config.js";
1212
import {
@@ -285,28 +285,11 @@ export async function monitorIMessageProvider(
285285
},
286286
});
287287

288-
const replyResult = await getReplyFromConfig(
289-
ctxPayload,
290-
{
291-
onToolResult: (payload) => {
292-
dispatcher.sendToolResult(payload);
293-
},
294-
onBlockReply: (payload) => {
295-
dispatcher.sendBlockReply(payload);
296-
},
297-
},
288+
const { queuedFinal } = await dispatchReplyFromConfig({
289+
ctx: ctxPayload,
298290
cfg,
299-
);
300-
const replies = replyResult
301-
? Array.isArray(replyResult)
302-
? replyResult
303-
: [replyResult]
304-
: [];
305-
let queuedFinal = false;
306-
for (const reply of replies) {
307-
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
308-
}
309-
await dispatcher.waitForIdle();
291+
dispatcher,
292+
});
310293
if (!queuedFinal) return;
311294
};
312295

src/signal/monitor.ts

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
22
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
3+
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
34
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
4-
import { getReplyFromConfig } from "../auto-reply/reply.js";
55
import type { ReplyPayload } from "../auto-reply/types.js";
66
import { loadConfig } from "../config/config.js";
77
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
@@ -400,28 +400,11 @@ export async function monitorSignalProvider(
400400
},
401401
});
402402

403-
const replyResult = await getReplyFromConfig(
404-
ctxPayload,
405-
{
406-
onToolResult: (payload) => {
407-
dispatcher.sendToolResult(payload);
408-
},
409-
onBlockReply: (payload) => {
410-
dispatcher.sendBlockReply(payload);
411-
},
412-
},
403+
const { queuedFinal } = await dispatchReplyFromConfig({
404+
ctx: ctxPayload,
413405
cfg,
414-
);
415-
const replies = replyResult
416-
? Array.isArray(replyResult)
417-
? replyResult
418-
: [replyResult]
419-
: [];
420-
let queuedFinal = false;
421-
for (const reply of replies) {
422-
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
423-
}
424-
await dispatcher.waitForIdle();
406+
dispatcher,
407+
});
425408
if (!queuedFinal) return;
426409
};
427410

src/slack/monitor.ts

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import bolt from "@slack/bolt";
66
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
77
import { hasControlCommand } from "../auto-reply/command-detection.js";
88
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
9+
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
910
import {
1011
buildMentionRegexes,
1112
matchesMentionPatterns,
@@ -757,31 +758,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
757758
},
758759
});
759760

760-
const replyResult = await getReplyFromConfig(
761-
ctxPayload,
762-
{
763-
onToolResult: (payload) => {
764-
dispatcher.sendToolResult(payload);
765-
},
766-
onBlockReply: (payload) => {
767-
dispatcher.sendBlockReply(payload);
768-
},
769-
},
761+
const { queuedFinal, counts } = await dispatchReplyFromConfig({
762+
ctx: ctxPayload,
770763
cfg,
771-
);
772-
const replies = replyResult
773-
? Array.isArray(replyResult)
774-
? replyResult
775-
: [replyResult]
776-
: [];
777-
let queuedFinal = false;
778-
for (const reply of replies) {
779-
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
780-
}
781-
await dispatcher.waitForIdle();
764+
dispatcher,
765+
});
782766
if (!queuedFinal) return;
783767
if (shouldLogVerbose()) {
784-
const finalCount = dispatcher.getQueuedCounts().final;
768+
const finalCount = counts.final;
785769
logVerbose(
786770
`slack: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
787771
);

src/telegram/bot.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import { Bot, InputFile, webhookCallback } from "grammy";
77
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
88
import { hasControlCommand } from "../auto-reply/command-detection.js";
99
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
10+
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
1011
import {
1112
buildMentionRegexes,
1213
matchesMentionPatterns,
1314
} from "../auto-reply/reply/mentions.js";
1415
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
1516
import type { TypingController } from "../auto-reply/reply/typing.js";
16-
import { getReplyFromConfig } from "../auto-reply/reply.js";
1717
import type { ReplyPayload } from "../auto-reply/types.js";
1818
import type { ReplyToMode } from "../config/config.js";
1919
import { loadConfig } from "../config/config.js";
@@ -314,28 +314,17 @@ export function createTelegramBot(opts: TelegramBotOptions) {
314314
},
315315
});
316316

317-
const replyResult = await getReplyFromConfig(
318-
ctxPayload,
319-
{
317+
const { queuedFinal } = await dispatchReplyFromConfig({
318+
ctx: ctxPayload,
319+
cfg,
320+
dispatcher,
321+
replyOptions: {
320322
onReplyStart: sendTyping,
321323
onTypingController: (typing) => {
322324
typingController = typing;
323325
},
324-
onToolResult: dispatcher.sendToolResult,
325-
onBlockReply: dispatcher.sendBlockReply,
326326
},
327-
cfg,
328-
);
329-
const replies = replyResult
330-
? Array.isArray(replyResult)
331-
? replyResult
332-
: [replyResult]
333-
: [];
334-
let queuedFinal = false;
335-
for (const reply of replies) {
336-
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
337-
}
338-
await dispatcher.waitForIdle();
327+
});
339328
typingController?.markDispatchIdle();
340329
if (!queuedFinal) return;
341330
} catch (err) {

src/web/auto-reply.ts

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
HEARTBEAT_PROMPT,
1010
stripHeartbeatToken,
1111
} from "../auto-reply/heartbeat.js";
12+
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
1213
import {
1314
buildMentionRegexes,
1415
normalizeMentionText,
@@ -1193,8 +1194,8 @@ export async function monitorWebProvider(
11931194
},
11941195
});
11951196

1196-
const replyResult = await (replyResolver ?? getReplyFromConfig)(
1197-
{
1197+
const { queuedFinal } = await dispatchReplyFromConfig({
1198+
ctx: {
11981199
Body: combinedBody,
11991200
From: msg.from,
12001201
To: msg.to,
@@ -1217,31 +1218,16 @@ export async function monitorWebProvider(
12171218
WasMentioned: msg.wasMentioned,
12181219
Surface: "whatsapp",
12191220
},
1220-
{
1221+
cfg,
1222+
dispatcher,
1223+
replyResolver,
1224+
replyOptions: {
12211225
onReplyStart: msg.sendComposing,
12221226
onTypingController: (typing) => {
12231227
typingController = typing;
12241228
},
1225-
onToolResult: (payload) => {
1226-
dispatcher.sendToolResult(payload);
1227-
},
1228-
onBlockReply: (payload) => {
1229-
dispatcher.sendBlockReply(payload);
1230-
},
12311229
},
1232-
);
1233-
1234-
const replyList = replyResult
1235-
? Array.isArray(replyResult)
1236-
? replyResult
1237-
: [replyResult]
1238-
: [];
1239-
1240-
let queuedFinal = false;
1241-
for (const replyPayload of replyList) {
1242-
queuedFinal = dispatcher.sendFinalReply(replyPayload) || queuedFinal;
1243-
}
1244-
await dispatcher.waitForIdle();
1230+
});
12451231
typingController?.markDispatchIdle();
12461232
if (!queuedFinal) {
12471233
if (shouldClearGroupHistory && didSendReply) {

0 commit comments

Comments
 (0)