refactor: move channel delivery and ACP seams into plugins

This commit is contained in:
Peter Steinberger
2026-03-15 23:24:18 -07:00
parent d5b12f505c
commit d163278e9c
24 changed files with 1177 additions and 646 deletions

View File

@@ -1,31 +1,125 @@
import { markdownToSignalTextChunks } from "../../../../extensions/signal/src/format.js";
import { sendMessageSignal } from "../../../../extensions/signal/src/send.js";
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js";
import {
resolveOutboundSendDep,
type OutboundSendDeps,
} from "../../../infra/outbound/send-deps.js";
import {
createScopedChannelMediaMaxBytesResolver,
createDirectTextMediaOutbound,
} from "./direct-text-media.js";
import type { ChannelOutboundAdapter } from "../types.js";
import { createScopedChannelMediaMaxBytesResolver } from "./direct-text-media.js";
function resolveSignalSender(deps: OutboundSendDeps | undefined) {
return resolveOutboundSendDep<typeof sendMessageSignal>(deps, "signal") ?? sendMessageSignal;
}
export const signalOutbound = createDirectTextMediaOutbound({
channel: "signal",
resolveSender: resolveSignalSender,
resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("signal"),
buildTextOptions: ({ cfg, maxBytes, accountId }) => ({
cfg,
maxBytes,
accountId: accountId ?? undefined,
}),
buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, mediaLocalRoots }) => ({
const resolveSignalMaxBytes = createScopedChannelMediaMaxBytesResolver("signal");
type SignalSendOpts = NonNullable<Parameters<typeof sendMessageSignal>[2]>;
function inferSignalTableMode(params: { cfg: SignalSendOpts["cfg"]; accountId?: string | null }) {
return resolveMarkdownTableMode({
cfg: params.cfg,
channel: "signal",
accountId: params.accountId ?? undefined,
});
}
export const signalOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: (text, _limit) => text.split(/\n{2,}/).flatMap((chunk) => (chunk ? [chunk] : [])),
chunkerMode: "text",
textChunkLimit: 4000,
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
const limit = resolveTextChunkLimit(cfg, "signal", accountId ?? undefined, {
fallbackLimit: 4000,
});
const tableMode = inferSignalTableMode({ cfg, accountId });
let chunks =
limit === undefined
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { tableMode })
: markdownToSignalTextChunks(text, limit, { tableMode });
if (chunks.length === 0 && text) {
chunks = [{ text, styles: [] }];
}
const results = [];
for (const chunk of chunks) {
abortSignal?.throwIfAborted();
const result = await send(to, chunk.text, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
textMode: "plain",
textStyles: chunk.styles,
});
results.push({ channel: "signal" as const, ...result });
}
return results;
},
sendFormattedMedia: async ({
cfg,
to,
text,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
mediaLocalRoots,
}),
});
accountId,
deps,
abortSignal,
}) => {
abortSignal?.throwIfAborted();
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
const tableMode = inferSignalTableMode({ cfg, accountId });
const formatted = markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, {
tableMode,
})[0] ?? {
text,
styles: [],
};
const result = await send(to, formatted.text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
textMode: "plain",
textStyles: formatted.styles,
mediaLocalRoots,
});
return { channel: "signal", ...result };
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
const result = await send(to, text, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
const result = await send(to, text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
mediaLocalRoots,
});
return { channel: "signal", ...result };
},
};

View File

@@ -1,11 +1,13 @@
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { AgentAcpBinding } from "../../config/types.js";
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js";
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
import type { OutboundIdentity } from "../../infra/outbound/identity.js";
import type { PluginRuntime } from "../../plugins/runtime/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { ConfigWriteTarget } from "./config-writes.js";
import type {
ChannelAccountSnapshot,
ChannelAccountState,
@@ -137,12 +139,23 @@ export type ChannelOutboundPayloadContext = ChannelOutboundContext & {
payload: ReplyPayload;
};
export type ChannelOutboundFormattedContext = ChannelOutboundContext & {
abortSignal?: AbortSignal;
};
export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text: string, limit: number) => string[]) | null;
chunkerMode?: "text" | "markdown";
textChunkLimit?: number;
pollMaxOptions?: number;
normalizePayload?: (params: { payload: ReplyPayload }) => ReplyPayload | null;
shouldSkipPlainTextSanitization?: (params: { payload: ReplyPayload }) => boolean;
resolveEffectiveTextChunkLimit?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
fallbackLimit?: number;
}) => number | undefined;
resolveTarget?: (params: {
cfg?: OpenClawConfig;
to?: string;
@@ -151,6 +164,10 @@ export type ChannelOutboundAdapter = {
mode?: ChannelOutboundTargetMode;
}) => { ok: true; to: string } | { ok: false; error: Error };
sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise<OutboundDeliveryResult>;
sendFormattedText?: (ctx: ChannelOutboundFormattedContext) => Promise<OutboundDeliveryResult[]>;
sendFormattedMedia?: (
ctx: ChannelOutboundFormattedContext & { mediaUrl: string },
) => Promise<OutboundDeliveryResult>;
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
@@ -464,9 +481,63 @@ export type ChannelExecApprovalAdapter = {
};
export type ChannelAllowlistAdapter = {
readConfig?: (params: { cfg: OpenClawConfig; accountId?: string | null }) =>
| {
dmAllowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
dmPolicy?: string;
groupPolicy?: string;
groupOverrides?: Array<{ label: string; entries: Array<string | number> }>;
}
| Promise<{
dmAllowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
dmPolicy?: string;
groupPolicy?: string;
groupOverrides?: Array<{ label: string; entries: Array<string | number> }>;
}>;
resolveNames?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
scope: "dm" | "group";
entries: string[];
}) =>
| Array<{ input: string; resolved: boolean; name?: string | null }>
| Promise<Array<{ input: string; resolved: boolean; name?: string | null }>>;
resolveConfigEdit?: (params: {
scope: "dm" | "group";
pathPrefix: string;
writeTarget: ConfigWriteTarget;
}) => {
pathPrefix: string;
writeTarget: ConfigWriteTarget;
readPaths: string[][];
writePath: string[];
cleanupPaths?: string[][];
} | null;
supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean;
};
export type ChannelAcpBindingAdapter = {
normalizeConfiguredBindingTarget?: (params: {
binding: AgentAcpBinding;
conversationId: string;
}) => {
conversationId: string;
parentConversationId?: string;
} | null;
matchConfiguredBinding?: (params: {
binding: AgentAcpBinding;
bindingConversationId: string;
conversationId: string;
parentConversationId?: string;
}) => {
conversationId: string;
parentConversationId?: string;
matchPriority?: number;
} | null;
};
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {
resolveDmPolicy?: (
ctx: ChannelSecurityContext<ResolvedAccount>,

View File

@@ -345,6 +345,12 @@ export type ChannelThreadingToolContext = {
export type ChannelMessagingAdapter = {
normalizeTarget?: (raw: string) => string | undefined;
parseExplicitTarget?: (params: { raw: string }) => {
to: string;
threadId?: string | number;
chatType?: ChatType;
} | null;
inferTargetChatType?: (params: { to: string }) => ChatType | undefined;
buildCrossContextComponents?: ChannelCrossContextComponentsFactory;
enableInteractiveReplies?: (params: {
cfg: OpenClawConfig;

View File

@@ -17,6 +17,7 @@ import type {
ChannelSetupAdapter,
ChannelStatusAdapter,
ChannelAllowlistAdapter,
ChannelAcpBindingAdapter,
} from "./types.adapters.js";
import type {
ChannelAgentTool,
@@ -77,6 +78,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
lifecycle?: ChannelLifecycleAdapter;
execApprovals?: ChannelExecApprovalAdapter;
allowlist?: ChannelAllowlistAdapter;
acpBindings?: ChannelAcpBindingAdapter;
streaming?: ChannelStreamingAdapter;
threading?: ChannelThreadingAdapter;
messaging?: ChannelMessagingAdapter;

View File

@@ -33,6 +33,7 @@ export type {
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelAllowlistAdapter,
ChannelAcpBindingAdapter,
ChannelPairingAdapter,
ChannelSecurityAdapter,
ChannelSetupAdapter,