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,99 +1,10 @@
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
import type { PluginPackageChannel } from "../plugins/manifest.js";
import { buildChatChannelMetaById, type ChatChannelMeta } from "./chat-meta-shared.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "./ids.js";
import type { ChannelMeta } from "./plugins/types.js";
export type ChatChannelMeta = ChannelMeta;
const CHAT_CHANNEL_ID_SET = new Set<string>(CHAT_CHANNEL_ORDER);
function toChatChannelMeta(params: {
id: ChatChannelId;
channel: PluginPackageChannel;
}): ChatChannelMeta {
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, ChatChannelMeta> {
const entries = new Map<ChatChannelId, ChatChannelMeta>();
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 || !CHAT_CHANNEL_ID_SET.has(rawId)) {
continue;
}
const id = rawId;
entries.set(
id,
toChatChannelMeta({
id,
channel,
}),
);
}
return Object.freeze(Object.fromEntries(entries)) as Record<ChatChannelId, ChatChannelMeta>;
}
const CHAT_CHANNEL_META = buildChatChannelMetaById();
export type { ChatChannelMeta };
export function listChatChannels(): ChatChannelMeta[] {
return CHAT_CHANNEL_ORDER.map((id) => CHAT_CHANNEL_META[id]);
}

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { createJiti } from "jiti";
import { openBoundaryFileSync } from "../../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
@@ -23,11 +24,49 @@ type GeneratedBundledChannelEntry = {
};
};
type BundledChannelDiscoveryCandidate = {
rootDir: string;
packageManifest?: {
extensions?: string[];
};
};
const log = createSubsystemLogger("channels");
function resolveChannelPluginModuleEntry(
moduleExport: unknown,
): GeneratedBundledChannelEntry["entry"] | null {
const resolveNamedFallback = (value: unknown): GeneratedBundledChannelEntry["entry"] | null => {
if (!value || typeof value !== "object") {
return null;
}
const entries = Object.entries(value as Record<string, unknown>).filter(
([key]) => key !== "default",
);
const pluginCandidates = entries.filter(
([key, candidate]) =>
key.endsWith("Plugin") &&
!!candidate &&
typeof candidate === "object" &&
"id" in (candidate as Record<string, unknown>),
);
if (pluginCandidates.length !== 1) {
return null;
}
const runtimeCandidates = entries.filter(
([key, candidate]) =>
key.startsWith("set") && key.endsWith("Runtime") && typeof candidate === "function",
);
return {
channelPlugin: pluginCandidates[0][1] as ChannelPlugin,
...(runtimeCandidates.length === 1
? {
setChannelRuntime: runtimeCandidates[0][1] as (runtime: PluginRuntime) => void,
}
: {}),
};
};
const resolved =
moduleExport &&
typeof moduleExport === "object" &&
@@ -42,7 +81,7 @@ function resolveChannelPluginModuleEntry(
setChannelRuntime?: unknown;
};
if (!record.channelPlugin || typeof record.channelPlugin !== "object") {
return null;
return resolveNamedFallback(resolved) ?? resolveNamedFallback(moduleExport);
}
return {
channelPlugin: record.channelPlugin as ChannelPlugin,
@@ -79,7 +118,8 @@ function createModuleLoader() {
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
return (modulePath: string) => {
const tryNative = shouldPreferNativeJiti(modulePath);
const tryNative =
shouldPreferNativeJiti(modulePath) || modulePath.includes(`${path.sep}dist${path.sep}`);
const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url);
const cacheKey = JSON.stringify({
tryNative,
@@ -101,9 +141,10 @@ function createModuleLoader() {
const loadModule = createModuleLoader();
function loadBundledModule(modulePath: string, rootDir: string): unknown {
const boundaryRoot = resolveCompiledBundledModulePath(rootDir);
const opened = openBoundaryFileSync({
absolutePath: modulePath,
rootPath: rootDir,
rootPath: boundaryRoot,
boundaryLabel: "plugin root",
rejectHardlinks: false,
skipLexicalRootCheck: true,
@@ -116,6 +157,29 @@ function loadBundledModule(modulePath: string, rootDir: string): unknown {
return loadModule(safePath)(safePath);
}
function resolveCompiledBundledModulePath(modulePath: string): string {
const compiledDistModulePath = modulePath.replace(
`${path.sep}dist-runtime${path.sep}`,
`${path.sep}dist${path.sep}`,
);
return compiledDistModulePath !== modulePath && fs.existsSync(compiledDistModulePath)
? compiledDistModulePath
: modulePath;
}
function resolvePreferredBundledChannelSource(
candidate: BundledChannelDiscoveryCandidate,
manifest: ReturnType<typeof loadPluginManifestRegistry>["plugins"][number],
): string {
const declaredEntry = candidate.packageManifest?.extensions?.find(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
if (declaredEntry) {
return resolveCompiledBundledModulePath(path.resolve(candidate.rootDir, declaredEntry));
}
return resolveCompiledBundledModulePath(manifest.source);
}
function loadGeneratedBundledChannelEntries(): readonly GeneratedBundledChannelEntry[] {
const discovery = discoverOpenClawPlugins({ cache: false });
const manifestRegistry = loadPluginManifestRegistry({
@@ -141,17 +205,23 @@ function loadGeneratedBundledChannelEntries(): readonly GeneratedBundledChannelE
seenIds.add(manifest.id);
try {
const sourcePath = resolvePreferredBundledChannelSource(candidate, manifest);
const entry = resolveChannelPluginModuleEntry(
loadBundledModule(candidate.source, candidate.rootDir),
loadBundledModule(sourcePath, candidate.rootDir),
);
if (!entry) {
log.warn(
`[channels] bundled channel entry ${manifest.id} missing channelPlugin export; skipping`,
`[channels] bundled channel entry ${manifest.id} missing channelPlugin export from ${sourcePath}; skipping`,
);
continue;
}
const setupEntry = manifest.setupSource
? resolveChannelSetupModuleEntry(loadBundledModule(manifest.setupSource, candidate.rootDir))
? resolveChannelSetupModuleEntry(
loadBundledModule(
resolveCompiledBundledModulePath(manifest.setupSource),
candidate.rootDir,
),
)
: null;
entries.push({
id: manifest.id,

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 } : {}),