Skip to content

Commit 9b7002e

Browse files
committed
refactor(reply): type reply threading policy
1 parent 456ad88 commit 9b7002e

24 files changed

Lines changed: 128 additions & 86 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
1a70d4d4f34ba5d0708a17540c0cbf1c98f50d37f25d2f71ad99b8bf6856cf9b plugin-sdk-api-baseline.json
2-
99cbe863efbed5ab42e0e7053d9486179aa689807696f0ebc4f4b89f1fe8cdfd plugin-sdk-api-baseline.jsonl
1+
97509287d728c8f5d1736f7ea07521451ada4b9d7ef56555dbe860a89e1b6e08 plugin-sdk-api-baseline.json
2+
a22b3d427953cc8394b28c87ef7a992d2eb4f2c9f6a76fa58b33079e2306661b plugin-sdk-api-baseline.jsonl

docs/channels/slack.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
338338

339339
Reply threading controls:
340340

341-
- `channels.slack.replyToMode`: `off|first|all` (default `off`)
341+
- `channels.slack.replyToMode`: `off|first|all|batched` (default `off`)
342342
- `channels.slack.replyToModeByChatType`: per `direct|group|channel`
343343
- legacy fallback for direct chats: `channels.slack.dm.replyToMode`
344344

docs/gateway/configuration-reference.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
179179
{ command: "generate", description: "Create an image" },
180180
],
181181
historyLimit: 50,
182-
replyToMode: "first", // off | first | all
182+
replyToMode: "first", // off | first | all | batched
183183
linkPreview: true,
184184
streaming: "partial", // off | partial | block | progress (default: off; opt in explicitly to avoid preview-edit rate limits)
185185
actions: { reactions: true, sendMessage: true },
@@ -239,7 +239,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
239239
events: true,
240240
moderation: false,
241241
},
242-
replyToMode: "off", // off | first | all
242+
replyToMode: "off", // off | first | all | batched
243243
dmPolicy: "pairing",
244244
allowFrom: ["1234567890", "123456789012345678"],
245245
dm: { enabled: true, groupEnabled: false, groupChannels: ["openclaw-dm"] },
@@ -405,7 +405,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
405405
allowBots: false,
406406
reactionNotifications: "own",
407407
reactionAllowlist: ["U123"],
408-
replyToMode: "off", // off | first | all
408+
replyToMode: "off", // off | first | all | batched
409409
thread: {
410410
historyScope: "thread", // thread | channel
411411
inheritParent: false,

extensions/discord/src/monitor/message-handler.batch-gate.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,18 @@ describe("applyImplicitReplyBatchGate", () => {
55
it("leaves context unchanged when replyToMode is not batched", () => {
66
const ctx: Record<string, unknown> = {};
77
applyImplicitReplyBatchGate(ctx, "first", true);
8-
expect(ctx.AllowImplicitReplyToCurrentMessage).toBeUndefined();
8+
expect(ctx.ReplyThreading).toBeUndefined();
99
});
1010

1111
it("marks single-message turns as not eligible for implicit reply refs", () => {
1212
const ctx: Record<string, unknown> = {};
1313
applyImplicitReplyBatchGate(ctx, "batched", false);
14-
expect(ctx.AllowImplicitReplyToCurrentMessage).toBe(false);
14+
expect(ctx.ReplyThreading).toEqual({ implicitCurrentMessage: "deny" });
1515
});
1616

1717
it("marks batched turns as eligible for implicit reply refs", () => {
1818
const ctx: Record<string, unknown> = {};
1919
applyImplicitReplyBatchGate(ctx, "batched", true);
20-
expect(ctx.AllowImplicitReplyToCurrentMessage).toBe(true);
20+
expect(ctx.ReplyThreading).toEqual({ implicitCurrentMessage: "allow" });
2121
});
2222
});
Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
2+
import type { ReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference";
3+
import { resolveBatchedReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference";
24

3-
export function applyImplicitReplyBatchGate(
4-
ctx: Record<string, unknown>,
5+
type ReplyThreadingContext = {
6+
ReplyThreading?: ReplyThreadingPolicy;
7+
};
8+
9+
export function applyImplicitReplyBatchGate<T extends object>(
10+
ctx: T,
511
replyToMode: ReplyToMode,
612
isBatched: boolean,
713
) {
8-
if (replyToMode !== "batched") {
14+
const replyThreading = resolveBatchedReplyThreadingPolicy(replyToMode, isBatched);
15+
if (!replyThreading) {
916
return;
1017
}
11-
ctx.AllowImplicitReplyToCurrentMessage = isBatched;
18+
(ctx as T & ReplyThreadingContext).ReplyThreading = replyThreading;
1219
}

extensions/discord/src/monitor/message-handler.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,7 @@ export function createDiscordMessageHandler(
145145
if (!ctx) {
146146
return;
147147
}
148-
applyImplicitReplyBatchGate(
149-
ctx as unknown as Record<string, unknown>,
150-
params.replyToMode,
151-
false,
152-
);
148+
applyImplicitReplyBatchGate(ctx, params.replyToMode, false);
153149
inboundWorker.enqueue(buildDiscordInboundJob(ctx));
154150
return;
155151
}
@@ -182,11 +178,7 @@ export function createDiscordMessageHandler(
182178
if (!ctx) {
183179
return;
184180
}
185-
applyImplicitReplyBatchGate(
186-
ctx as unknown as Record<string, unknown>,
187-
params.replyToMode,
188-
true,
189-
);
181+
applyImplicitReplyBatchGate(ctx, params.replyToMode, true);
190182
if (entries.length > 1) {
191183
const ids = entries.map((entry) => entry.data.message?.id).filter(Boolean) as string[];
192184
if (ids.length > 0) {

extensions/discord/src/monitor/reply-delivery.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
resolveTextChunksWithFallback,
1010
sendMediaWithLeadingCaption,
1111
} from "openclaw/plugin-sdk/reply-payload";
12+
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
1213
import {
1314
resolveRetryConfig,
1415
retryAsync,
@@ -263,8 +264,7 @@ export async function deliverDiscordReply(params: {
263264
const chunkLimit = Math.min(params.textLimit, 2000);
264265
const replyTo = params.replyToId?.trim() || undefined;
265266
const replyToMode = params.replyToMode ?? "all";
266-
// replyToMode=first should only apply to the first physical send.
267-
const replyOnce = replyToMode === "first" || replyToMode === "batched";
267+
const replyOnce = isSingleUseReplyToMode(replyToMode);
268268
let replyUsed = false;
269269
const resolveReplyTo = () => {
270270
if (!replyTo) {

extensions/discord/src/test-support/component-runtime.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
12
import { vi, type Mock } from "vitest";
23
import { parsePluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js";
34
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../src/security/dm-policy-shared.js";
@@ -94,10 +95,7 @@ vi.mock("../monitor/agent-components.runtime.js", () => {
9495
if (params.replyToMode === "off") {
9596
return undefined;
9697
}
97-
if (
98-
(params.replyToMode === "first" || params.replyToMode === "batched") &&
99-
hasReplied
100-
) {
98+
if (isSingleUseReplyToMode(params.replyToMode ?? "off") && hasReplied) {
10199
return undefined;
102100
}
103101
const value = nextId;

extensions/matrix/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export type MatrixConfig = {
142142
blockStreaming?: boolean;
143143
/** Allowlist for group senders (matrix user IDs). */
144144
groupAllowFrom?: Array<string | number>;
145-
/** Control reply threading when reply tags are present (off|first|all). */
145+
/** Control reply threading when reply tags are present (off|first|all|batched). */
146146
replyToMode?: ReplyToMode;
147147
/** How to handle thread replies (off|inbound|always). */
148148
threadReplies?: "off" | "inbound" | "always";

extensions/slack/src/action-runtime.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
2+
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
23
import { parseSlackBlocksInput } from "./blocks-input.js";
34
import {
45
createActionGate,
@@ -77,7 +78,7 @@ export type SlackActionContext = {
7778
currentThreadTs?: string;
7879
/** Reply-to mode for auto-threading. */
7980
replyToMode?: "off" | "first" | "all" | "batched";
80-
/** Mutable ref to track if a reply was sent (for "first" mode). */
81+
/** Mutable ref to track if a reply was sent for single-use reply modes. */
8182
hasRepliedRef?: { value: boolean };
8283
/** Allowed local media directories for file uploads. */
8384
mediaLocalRoots?: readonly string[];
@@ -87,7 +88,7 @@ export type SlackActionContext = {
8788
/**
8889
* Resolve threadTs for a Slack message based on context and replyToMode.
8990
* - "all": always inject threadTs
90-
* - "first": inject only for first message (updates hasRepliedRef)
91+
* - "first"/"batched": inject only for the first eligible message (updates hasRepliedRef)
9192
* - "off": never auto-inject
9293
*/
9394
function resolveThreadTsFromContext(
@@ -122,7 +123,7 @@ function resolveThreadTsFromContext(
122123
return context.currentThreadTs;
123124
}
124125
if (
125-
(context.replyToMode === "first" || context.replyToMode === "batched") &&
126+
isSingleUseReplyToMode(context.replyToMode ?? "off") &&
126127
context.hasRepliedRef &&
127128
!context.hasRepliedRef.value
128129
) {

0 commit comments

Comments
 (0)