refactor(outbound): share reply fanout policy

This commit is contained in:
Peter Steinberger
2026-04-25 03:05:16 +01:00
parent 2f23511ffa
commit 7ef4ecf499
5 changed files with 67 additions and 47 deletions

View File

@@ -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<DiscordSendFn>(ctx.deps, "discord") ?? runtime.sendMessageDiscord,

View File

@@ -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"]);
});
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,