diff --git a/extensions/discord/src/outbound-send-context.ts b/extensions/discord/src/outbound-send-context.ts index 481c52ec8f7..519357f901b 100644 --- a/extensions/discord/src/outbound-send-context.ts +++ b/extensions/discord/src/outbound-send-context.ts @@ -1,13 +1,11 @@ import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; import { + createReplyToFanout, resolveOutboundSendDep, + type ReplyToResolution, type OutboundSendDeps, } from "openclaw/plugin-sdk/outbound-runtime"; -import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference"; -import { - normalizeOptionalString, - normalizeOptionalStringifiedId, -} from "openclaw/plugin-sdk/text-runtime"; +import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/text-runtime"; import { withDiscordDeliveryRetry } from "./delivery-retry.js"; type DiscordSendRuntime = typeof import("./send.js"); @@ -54,31 +52,13 @@ export function resolveDiscordFormattingOptions(ctx: { }; } -export function createResolvedReplyToFanout(params: { - replyToId?: string | null; - replyToMode?: ReplyToMode; -}): () => string | undefined { - const replyToId = normalizeOptionalString(params.replyToId); - if (!replyToId) { - return () => undefined; - } - if (!params.replyToMode || !isSingleUseReplyToMode(params.replyToMode)) { - return () => replyToId; - } - let current: string | undefined = replyToId; - return () => { - const value = current; - current = undefined; - return value; - }; -} - export async function createDiscordPayloadSendContext(ctx: { cfg: OpenClawConfig; to: string; accountId?: string | null; deps?: OutboundSendDeps; replyToId?: string | null; + replyToIdSource?: ReplyToResolution["source"]; replyToMode?: ReplyToMode; formatting?: DiscordFormattingOptions; threadId?: string | number | null; @@ -94,8 +74,9 @@ export async function createDiscordPayloadSendContext(ctx: { return { target: resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId }), formatting: resolveDiscordFormattingOptions(ctx), - resolveReplyTo: createResolvedReplyToFanout({ + resolveReplyTo: createReplyToFanout({ replyToId: ctx.replyToId, + replyToIdSource: ctx.replyToIdSource, replyToMode: ctx.replyToMode, }), send: resolveOutboundSendDep(ctx.deps, "discord") ?? runtime.sendMessageDiscord, diff --git a/src/infra/outbound/reply-policy.test.ts b/src/infra/outbound/reply-policy.test.ts new file mode 100644 index 00000000000..268acd4028d --- /dev/null +++ b/src/infra/outbound/reply-policy.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { createReplyToFanout } from "./reply-policy.js"; + +describe("createReplyToFanout", () => { + it("consumes implicit single-use replies once", () => { + const next = createReplyToFanout({ + replyToId: "reply-1", + replyToIdSource: "implicit", + replyToMode: "first", + }); + + expect([next(), next(), next()]).toEqual(["reply-1", undefined, undefined]); + }); + + it("keeps explicit replies reusable even in single-use modes", () => { + const next = createReplyToFanout({ + replyToId: "reply-1", + replyToIdSource: "explicit", + replyToMode: "first", + }); + + expect([next(), next()]).toEqual(["reply-1", "reply-1"]); + }); + + it("keeps all-mode replies reusable", () => { + const next = createReplyToFanout({ + replyToId: "reply-1", + replyToIdSource: "implicit", + replyToMode: "all", + }); + + expect([next(), next()]).toEqual(["reply-1", "reply-1"]); + }); +}); diff --git a/src/infra/outbound/reply-policy.ts b/src/infra/outbound/reply-policy.ts index f8a53bb727a..11889333c8c 100644 --- a/src/infra/outbound/reply-policy.ts +++ b/src/infra/outbound/reply-policy.ts @@ -12,6 +12,30 @@ export type ReplyToResolution = { source?: "explicit" | "implicit"; }; +export function createReplyToFanout(params: { + replyToId?: string | null; + replyToMode?: ReplyToMode; + replyToIdSource?: ReplyToResolution["source"]; +}): () => string | undefined { + const replyToId = params.replyToId ?? undefined; + if (!replyToId) { + return () => undefined; + } + const singleUse = + params.replyToIdSource !== "explicit" && + params.replyToMode !== undefined && + isSingleUseReplyToMode(params.replyToMode); + if (!singleUse) { + return () => replyToId; + } + let current: string | undefined = replyToId; + return () => { + const value = current; + current = undefined; + return value; + }; +} + export function createReplyToDeliveryPolicy(params: { replyToId?: string | null; replyToMode?: ReplyToMode; diff --git a/src/plugin-sdk/outbound-runtime.ts b/src/plugin-sdk/outbound-runtime.ts index 4b2913aa200..35bdf67aa4f 100644 --- a/src/plugin-sdk/outbound-runtime.ts +++ b/src/plugin-sdk/outbound-runtime.ts @@ -2,6 +2,7 @@ export { createRuntimeOutboundDelegates } from "../channels/plugins/runtime-forw export { resolveOutboundSendDep, type OutboundSendDeps } from "../infra/outbound/send-deps.js"; export { resolveAgentOutboundIdentity, type OutboundIdentity } from "../infra/outbound/identity.js"; export type { OutboundDeliveryFormattingOptions } from "../infra/outbound/formatting.js"; +export { createReplyToFanout, type ReplyToResolution } from "../infra/outbound/reply-policy.js"; export { deliverOutboundPayloads, type DeliverOutboundPayloadsParams, diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index fc93dcbb1bd..b0b663585a6 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -1,6 +1,6 @@ import type { ReplyPayload as InternalReplyPayload } from "../auto-reply/reply-payload.js"; -import { isSingleUseReplyToMode } from "../auto-reply/reply/reply-reference.js"; import type { ChannelOutboundAdapter } from "../channels/plugins/outbound.types.js"; +import { createReplyToFanout } from "../infra/outbound/reply-policy.js"; import { normalizeLowercaseStringOrEmpty, readStringValue } from "../shared/string-coerce.js"; export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-payload.js"; @@ -39,26 +39,6 @@ type SendPayloadAdapter = Pick< const REASONING_PREFIX = "reasoning:"; -function createSendPayloadReplyToFanout(ctx: SendPayloadContext): () => string | undefined { - const replyToId = ctx.replyToId ?? undefined; - if (!replyToId) { - return () => undefined; - } - const singleUse = - ctx.replyToIdSource !== "explicit" && - ctx.replyToMode !== undefined && - isSingleUseReplyToMode(ctx.replyToMode); - if (!singleUse) { - return () => replyToId; - } - let current: string | undefined = replyToId; - return () => { - const value = current; - current = undefined; - return value; - }; -} - function trimLeadingMarkdownQuoteMarkers(text: string): string { let candidate = text.trimStart(); while (candidate.startsWith(">")) { @@ -310,7 +290,7 @@ export async function sendTextMediaPayload(params: { if (!text && urls.length === 0) { return { channel: params.channel, messageId: "" }; } - const nextReplyToId = createSendPayloadReplyToFanout(params.ctx); + const nextReplyToId = createReplyToFanout(params.ctx); if (urls.length > 0) { const lastResult = await sendPayloadMediaSequence({ text,