refactor(plugins): narrow bundled channel core seams

This commit is contained in:
Peter Steinberger
2026-04-04 07:39:14 +01:00
parent 381ee4d218
commit 667a54a4b7
17 changed files with 847 additions and 305 deletions

View File

@@ -1,4 +1,3 @@
import type { ChannelAccountSnapshot } from "../channels/plugins/types.core.js";
import type { ChannelStatusIssue } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import {
@@ -7,7 +6,6 @@ import {
type ParsedChatTarget,
} from "./channel-targets.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
import { asString, collectIssuesForEnabledAccounts, isRecord } from "./status-helpers.js";
// Narrow plugin-sdk surface for the bundled BlueBubbles plugin.
// Keep this list additive and scoped to the conversation-binding seam only.
@@ -27,6 +25,7 @@ type BlueBubblesFacadeModule = {
accountId?: string;
cfg: OpenClawConfig;
}) => BlueBubblesConversationBindingManager;
collectBlueBubblesStatusIssues: (accounts: unknown[]) => ChannelStatusIssue[];
};
function loadBlueBubblesFacadeModule(): BlueBubblesFacadeModule {
@@ -266,99 +265,8 @@ export function resolveBlueBubblesConversationIdFromTarget(target: string): stri
return normalizeBlueBubblesAcpConversationId(target)?.conversationId;
}
type BlueBubblesAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
running?: unknown;
baseUrl?: unknown;
lastError?: unknown;
probe?: unknown;
};
type BlueBubblesProbeResult = {
ok?: boolean;
status?: number | null;
error?: string | null;
};
function readBlueBubblesAccountStatus(
value: ChannelAccountSnapshot,
): BlueBubblesAccountStatus | null {
if (!isRecord(value)) {
return null;
}
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
running: value.running,
baseUrl: value.baseUrl,
lastError: value.lastError,
probe: value.probe,
};
}
function readBlueBubblesProbeResult(value: unknown): BlueBubblesProbeResult | null {
if (!isRecord(value)) {
return null;
}
return {
ok: typeof value.ok === "boolean" ? value.ok : undefined,
status: typeof value.status === "number" ? value.status : null,
error: asString(value.error) ?? null,
};
}
export function collectBlueBubblesStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
return collectIssuesForEnabledAccounts({
accounts,
readAccount: readBlueBubblesAccountStatus,
collectIssues: ({ account, accountId, issues }) => {
const configured = account.configured === true;
const running = account.running === true;
const lastError = asString(account.lastError);
const probe = readBlueBubblesProbeResult(account.probe);
if (!configured) {
issues.push({
channel: "bluebubbles",
accountId,
kind: "config",
message: "Not configured (missing serverUrl or password).",
fix: "Run: openclaw channels add bluebubbles --http-url <server-url> --password <password>",
});
return;
}
if (probe && probe.ok === false) {
const errorDetail = probe.error
? `: ${probe.error}`
: probe.status
? ` (HTTP ${probe.status})`
: "";
issues.push({
channel: "bluebubbles",
accountId,
kind: "runtime",
message: `BlueBubbles server unreachable${errorDetail}`,
fix: "Check that the BlueBubbles server is running and accessible. Verify serverUrl and password in your config.",
});
}
if (running && lastError) {
issues.push({
channel: "bluebubbles",
accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
fix: "Check gateway logs for details. If the webhook is failing, verify the webhook URL is configured in BlueBubbles server settings.",
});
}
},
});
export function collectBlueBubblesStatusIssues(accounts: unknown[]): ChannelStatusIssue[] {
return loadBlueBubblesFacadeModule().collectBlueBubblesStatusIssues(accounts);
}
export { resolveAckReaction } from "../agents/identity.js";

View File

@@ -0,0 +1,377 @@
import { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js";
import {
createScopedAccountReplyToModeResolver,
createTopLevelChannelReplyToModeResolver,
} from "../channels/plugins/threading-helpers.js";
import type {
ChannelOutboundAdapter,
ChannelPairingAdapter,
ChannelSecurityAdapter,
} from "../channels/plugins/types.adapters.js";
import type {
ChannelMessagingAdapter,
ChannelOutboundSessionRoute,
ChannelPollResult,
ChannelThreadingAdapter,
} from "../channels/plugins/types.core.js";
import type { ChannelConfigUiHint, ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ReplyToMode } from "../config/types.base.js";
import { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js";
import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
import { emptyPluginConfigSchema } from "../plugins/config-schema.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import type { OpenClawPluginApi, OpenClawPluginConfigSchema } from "../plugins/types.js";
export type { ChannelConfigUiHint, ChannelPlugin };
export type { OpenClawConfig };
export type { PluginRuntime };
export type ChannelOutboundSessionRouteParams = Parameters<
NonNullable<ChannelMessagingAdapter["resolveOutboundSessionRoute"]>
>[0];
type DefineChannelPluginEntryOptions<TPlugin = ChannelPlugin> = {
id: string;
name: string;
description: string;
plugin: TPlugin;
configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema);
setRuntime?: (runtime: PluginRuntime) => void;
registerCliMetadata?: (api: OpenClawPluginApi) => void;
registerFull?: (api: OpenClawPluginApi) => void;
};
type DefinedChannelPluginEntry<TPlugin> = {
id: string;
name: string;
description: string;
configSchema: OpenClawPluginConfigSchema;
register: (api: OpenClawPluginApi) => void;
channelPlugin: TPlugin;
setChannelRuntime?: (runtime: PluginRuntime) => void;
};
type ChatChannelPluginBase<TResolvedAccount, Probe, Audit> = Omit<
ChannelPlugin<TResolvedAccount, Probe, Audit>,
"security" | "pairing" | "threading" | "outbound"
> &
Partial<
Pick<
ChannelPlugin<TResolvedAccount, Probe, Audit>,
"security" | "pairing" | "threading" | "outbound"
>
>;
type ChatChannelSecurityOptions<TResolvedAccount extends { accountId?: string | null }> = {
dm: {
channelKey: string;
resolvePolicy: (account: TResolvedAccount) => string | null | undefined;
resolveAllowFrom: (account: TResolvedAccount) => Array<string | number> | null | undefined;
resolveFallbackAccountId?: (account: TResolvedAccount) => string | null | undefined;
defaultPolicy?: string;
allowFromPathSuffix?: string;
policyPathSuffix?: string;
approveChannelId?: string;
approveHint?: string;
normalizeEntry?: (raw: string) => string;
};
collectWarnings?: ChannelSecurityAdapter<TResolvedAccount>["collectWarnings"];
collectAuditFindings?: ChannelSecurityAdapter<TResolvedAccount>["collectAuditFindings"];
};
type ChatChannelPairingOptions = {
text: {
idLabel: string;
message: string;
normalizeAllowEntry?: ChannelPairingAdapter["normalizeAllowEntry"];
notify: (
params: Parameters<NonNullable<ChannelPairingAdapter["notifyApproval"]>>[0] & {
message: string;
},
) => Promise<void> | void;
};
};
type ChatChannelThreadingReplyModeOptions<TResolvedAccount> =
| { topLevelReplyToMode: string }
| {
scopedAccountReplyToMode: {
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TResolvedAccount;
resolveReplyToMode: (
account: TResolvedAccount,
chatType?: string | null,
) => ReplyToMode | null | undefined;
fallback?: ReplyToMode;
};
}
| {
resolveReplyToMode: NonNullable<ChannelThreadingAdapter["resolveReplyToMode"]>;
};
type ChatChannelThreadingOptions<TResolvedAccount> =
ChatChannelThreadingReplyModeOptions<TResolvedAccount> &
Omit<ChannelThreadingAdapter, "resolveReplyToMode">;
type ChatChannelAttachedOutboundOptions = {
base: Omit<ChannelOutboundAdapter, "sendText" | "sendMedia" | "sendPoll">;
attachedResults: {
channel: string;
sendText?: (
ctx: Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[0],
) => MaybePromise<Omit<OutboundDeliveryResult, "channel">>;
sendMedia?: (
ctx: Parameters<NonNullable<ChannelOutboundAdapter["sendMedia"]>>[0],
) => MaybePromise<Omit<OutboundDeliveryResult, "channel">>;
sendPoll?: (
ctx: Parameters<NonNullable<ChannelOutboundAdapter["sendPoll"]>>[0],
) => MaybePromise<Omit<ChannelPollResult, "channel">>;
};
};
type MaybePromise<T> = T | Promise<T>;
function createInlineTextPairingAdapter(params: {
idLabel: string;
message: string;
normalizeAllowEntry?: ChannelPairingAdapter["normalizeAllowEntry"];
notify: (
params: Parameters<NonNullable<ChannelPairingAdapter["notifyApproval"]>>[0] & {
message: string;
},
) => Promise<void> | void;
}): ChannelPairingAdapter {
return {
idLabel: params.idLabel,
normalizeAllowEntry: params.normalizeAllowEntry,
notifyApproval: async (ctx) => {
await params.notify({
...ctx,
message: params.message,
});
},
};
}
function createInlineAttachedChannelResultAdapter(
params: ChatChannelAttachedOutboundOptions["attachedResults"],
) {
return {
sendText: params.sendText
? async (ctx: Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[0]) => ({
channel: params.channel,
...(await params.sendText!(ctx)),
})
: undefined,
sendMedia: params.sendMedia
? async (ctx: Parameters<NonNullable<ChannelOutboundAdapter["sendMedia"]>>[0]) => ({
channel: params.channel,
...(await params.sendMedia!(ctx)),
})
: undefined,
sendPoll: params.sendPoll
? async (ctx: Parameters<NonNullable<ChannelOutboundAdapter["sendPoll"]>>[0]) => ({
channel: params.channel,
...(await params.sendPoll!(ctx)),
})
: undefined,
} satisfies Pick<ChannelOutboundAdapter, "sendText" | "sendMedia" | "sendPoll">;
}
function resolveChatChannelSecurity<TResolvedAccount extends { accountId?: string | null }>(
security:
| ChannelSecurityAdapter<TResolvedAccount>
| ChatChannelSecurityOptions<TResolvedAccount>
| undefined,
): ChannelSecurityAdapter<TResolvedAccount> | undefined {
if (!security) {
return undefined;
}
if (!("dm" in security)) {
return security;
}
return {
resolveDmPolicy: ({ cfg, accountId, account }) =>
buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: security.dm.channelKey,
accountId,
fallbackAccountId: security.dm.resolveFallbackAccountId?.(account) ?? account.accountId,
policy: security.dm.resolvePolicy(account),
allowFrom: security.dm.resolveAllowFrom(account) ?? [],
defaultPolicy: security.dm.defaultPolicy,
allowFromPathSuffix: security.dm.allowFromPathSuffix,
policyPathSuffix: security.dm.policyPathSuffix,
approveChannelId: security.dm.approveChannelId,
approveHint: security.dm.approveHint,
normalizeEntry: security.dm.normalizeEntry,
}),
...(security.collectWarnings ? { collectWarnings: security.collectWarnings } : {}),
...(security.collectAuditFindings
? { collectAuditFindings: security.collectAuditFindings }
: {}),
};
}
function resolveChatChannelPairing(
pairing: ChannelPairingAdapter | ChatChannelPairingOptions | undefined,
): ChannelPairingAdapter | undefined {
if (!pairing) {
return undefined;
}
if (!("text" in pairing)) {
return pairing;
}
return createInlineTextPairingAdapter(pairing.text);
}
function resolveChatChannelThreading<TResolvedAccount>(
threading: ChannelThreadingAdapter | ChatChannelThreadingOptions<TResolvedAccount> | undefined,
): ChannelThreadingAdapter | undefined {
if (!threading) {
return undefined;
}
if (!("topLevelReplyToMode" in threading) && !("scopedAccountReplyToMode" in threading)) {
return threading;
}
let resolveReplyToMode: ChannelThreadingAdapter["resolveReplyToMode"];
if ("topLevelReplyToMode" in threading) {
resolveReplyToMode = createTopLevelChannelReplyToModeResolver(threading.topLevelReplyToMode);
} else {
resolveReplyToMode = createScopedAccountReplyToModeResolver<TResolvedAccount>(
threading.scopedAccountReplyToMode,
);
}
return {
...threading,
resolveReplyToMode,
};
}
function resolveChatChannelOutbound(
outbound: ChannelOutboundAdapter | ChatChannelAttachedOutboundOptions | undefined,
): ChannelOutboundAdapter | undefined {
if (!outbound) {
return undefined;
}
if (!("attachedResults" in outbound)) {
return outbound;
}
return {
...outbound.base,
...createInlineAttachedChannelResultAdapter(outbound.attachedResults),
};
}
export function defineChannelPluginEntry<TPlugin>({
id,
name,
description,
plugin,
configSchema = emptyPluginConfigSchema,
setRuntime,
registerCliMetadata,
registerFull,
}: DefineChannelPluginEntryOptions<TPlugin>): DefinedChannelPluginEntry<TPlugin> {
const resolvedConfigSchema = typeof configSchema === "function" ? configSchema() : configSchema;
const entry = {
id,
name,
description,
configSchema: resolvedConfigSchema,
register(api: OpenClawPluginApi) {
if (api.registrationMode === "cli-metadata") {
registerCliMetadata?.(api);
return;
}
setRuntime?.(api.runtime);
api.registerChannel({ plugin: plugin as ChannelPlugin });
if (api.registrationMode !== "full") {
return;
}
registerCliMetadata?.(api);
registerFull?.(api);
},
};
return {
...entry,
channelPlugin: plugin,
...(setRuntime ? { setChannelRuntime: setRuntime } : {}),
};
}
export function defineSetupPluginEntry<TPlugin>(plugin: TPlugin) {
return { plugin };
}
export function stripChannelTargetPrefix(raw: string, ...providers: string[]): string {
const trimmed = raw.trim();
for (const provider of providers) {
const prefix = `${provider.toLowerCase()}:`;
if (trimmed.toLowerCase().startsWith(prefix)) {
return trimmed.slice(prefix.length).trim();
}
}
return trimmed;
}
export function stripTargetKindPrefix(raw: string): string {
return raw.replace(/^(user|channel|group|conversation|room|dm):/i, "").trim();
}
export function buildChannelOutboundSessionRoute(params: {
cfg: OpenClawConfig;
agentId: string;
channel: string;
accountId?: string | null;
peer: { kind: "direct" | "group" | "channel"; id: string };
chatType: "direct" | "group" | "channel";
from: string;
to: string;
threadId?: string | number;
}): ChannelOutboundSessionRoute {
const baseSessionKey = buildOutboundBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel: params.channel,
accountId: params.accountId,
peer: params.peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer: params.peer,
chatType: params.chatType,
from: params.from,
to: params.to,
...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
};
}
export function createChatChannelPlugin<
TResolvedAccount extends { accountId?: string | null },
Probe = unknown,
Audit = unknown,
>(params: {
base: ChatChannelPluginBase<TResolvedAccount, Probe, Audit>;
security?:
| ChannelSecurityAdapter<TResolvedAccount>
| ChatChannelSecurityOptions<TResolvedAccount>;
pairing?: ChannelPairingAdapter | ChatChannelPairingOptions;
threading?: ChannelThreadingAdapter | ChatChannelThreadingOptions<TResolvedAccount>;
outbound?: ChannelOutboundAdapter | ChatChannelAttachedOutboundOptions;
}): ChannelPlugin<TResolvedAccount, Probe, Audit> {
return {
...params.base,
conversationBindings: {
supportsCurrentConversationBinding: true,
...params.base.conversationBindings,
},
...(params.security ? { security: resolveChatChannelSecurity(params.security) } : {}),
...(params.pairing ? { pairing: resolveChatChannelPairing(params.pairing) } : {}),
...(params.threading ? { threading: resolveChatChannelThreading(params.threading) } : {}),
...(params.outbound ? { outbound: resolveChatChannelOutbound(params.outbound) } : {}),
} as ChannelPlugin<TResolvedAccount, Probe, Audit>;
}

View File

@@ -1,4 +1,4 @@
import { getChatChannelMeta } from "../channels/chat-meta.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "../channels/ids.js";
import { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js";
import {
createScopedAccountReplyToModeResolver,
@@ -15,12 +15,15 @@ import type {
ChannelPollResult,
ChannelThreadingAdapter,
} from "../channels/plugins/types.core.js";
import type { ChannelMeta } from "../channels/plugins/types.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ReplyToMode } from "../config/types.base.js";
import { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js";
import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
import { emptyPluginConfigSchema } from "../plugins/config-schema.js";
import type { PluginPackageChannel } from "../plugins/manifest.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import type { OpenClawPluginApi, OpenClawPluginConfigSchema } from "../plugins/types.js";
@@ -155,7 +158,6 @@ export {
formatPairingApproveHint,
parseOptionalDelimitedEntries,
} from "../channels/plugins/helpers.js";
export { getChatChannelMeta } from "../channels/chat-meta.js";
export {
channelTargetSchema,
channelTargetsSchema,
@@ -205,6 +207,105 @@ export type ChannelOutboundSessionRouteParams = Parameters<
NonNullable<ChannelMessagingAdapter["resolveOutboundSessionRoute"]>
>[0];
var cachedSdkChatChannelMeta: ReturnType<typeof buildChatChannelMetaById> | undefined;
var cachedSdkChatChannelIdSet: Set<string> | undefined;
function getSdkChatChannelIdSet(): Set<string> {
cachedSdkChatChannelIdSet ??= new Set(CHAT_CHANNEL_ORDER);
return cachedSdkChatChannelIdSet;
}
function toSdkChatChannelMeta(params: {
id: ChatChannelId;
channel: PluginPackageChannel;
}): ChannelMeta {
const label = params.channel.label?.trim();
if (!label) {
throw new Error(`Missing label for bundled chat channel "${params.id}"`);
}
return {
id: params.id,
label,
selectionLabel: params.channel.selectionLabel?.trim() || label,
docsPath: params.channel.docsPath?.trim() || `/channels/${params.id}`,
docsLabel: params.channel.docsLabel?.trim() || undefined,
blurb: params.channel.blurb?.trim() || "",
...(params.channel.aliases?.length ? { aliases: params.channel.aliases } : {}),
...(params.channel.order !== undefined ? { order: params.channel.order } : {}),
...(params.channel.selectionDocsPrefix !== undefined
? { selectionDocsPrefix: params.channel.selectionDocsPrefix }
: {}),
...(params.channel.selectionDocsOmitLabel !== undefined
? { selectionDocsOmitLabel: params.channel.selectionDocsOmitLabel }
: {}),
...(params.channel.selectionExtras?.length
? { selectionExtras: params.channel.selectionExtras }
: {}),
...(params.channel.detailLabel?.trim()
? { detailLabel: params.channel.detailLabel.trim() }
: {}),
...(params.channel.systemImage?.trim()
? { systemImage: params.channel.systemImage.trim() }
: {}),
...(params.channel.markdownCapable !== undefined
? { markdownCapable: params.channel.markdownCapable }
: {}),
...(params.channel.showConfigured !== undefined
? { showConfigured: params.channel.showConfigured }
: {}),
...(params.channel.quickstartAllowFrom !== undefined
? { quickstartAllowFrom: params.channel.quickstartAllowFrom }
: {}),
...(params.channel.forceAccountBinding !== undefined
? { forceAccountBinding: params.channel.forceAccountBinding }
: {}),
...(params.channel.preferSessionLookupForAnnounceTarget !== undefined
? {
preferSessionLookupForAnnounceTarget: params.channel.preferSessionLookupForAnnounceTarget,
}
: {}),
...(params.channel.preferOver?.length ? { preferOver: params.channel.preferOver } : {}),
};
}
function buildChatChannelMetaById(): Record<ChatChannelId, ChannelMeta> {
const entries = new Map<ChatChannelId, ChannelMeta>();
for (const entry of listBundledPluginMetadata({
includeChannelConfigs: true,
includeSyntheticChannelConfigs: false,
})) {
const channel =
entry.packageManifest && "channel" in entry.packageManifest
? entry.packageManifest.channel
: undefined;
if (!channel) {
continue;
}
const rawId = channel.id?.trim();
if (!rawId || !getSdkChatChannelIdSet().has(rawId)) {
continue;
}
const id = rawId;
entries.set(
id,
toSdkChatChannelMeta({
id,
channel,
}),
);
}
return Object.freeze(Object.fromEntries(entries)) as Record<ChatChannelId, ChannelMeta>;
}
function resolveSdkChatChannelMeta(id: string) {
cachedSdkChatChannelMeta ??= buildChatChannelMetaById();
return cachedSdkChatChannelMeta[id];
}
export function getChatChannelMeta(id: ChatChannelId): ChannelMeta {
return resolveSdkChatChannelMeta(id);
}
/** Remove one of the known provider prefixes from a free-form target string. */
export function stripChannelTargetPrefix(raw: string, ...providers: string[]): string {
const trimmed = raw.trim();
@@ -604,7 +705,7 @@ export function createChannelPluginBase<TResolvedAccount>(
return {
id: params.id,
meta: {
...getChatChannelMeta(params.id as Parameters<typeof getChatChannelMeta>[0]),
...resolveSdkChatChannelMeta(params.id),
...params.meta,
},
...(params.setupWizard ? { setupWizard: params.setupWizard } : {}),