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
No known key found for this signature in database
17 changed files with 847 additions and 305 deletions

View File

@ -1,6 +1,5 @@
export {
BLUEBUBBLES_ACTION_NAMES,
BLUEBUBBLES_ACTIONS,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
} from "./runtime-api.js";
export { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS } from "./actions-contract.js";
export type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-contract";

View File

@ -0,0 +1,19 @@
export const BLUEBUBBLES_ACTIONS = {
react: { gate: "reactions" },
edit: { gate: "edit", unsupportedOnMacOS26: true },
unsend: { gate: "unsend" },
reply: { gate: "reply" },
sendWithEffect: { gate: "sendWithEffect" },
renameGroup: { gate: "renameGroup", groupOnly: true },
setGroupIcon: { gate: "setGroupIcon", groupOnly: true },
addParticipant: { gate: "addParticipant", groupOnly: true },
removeParticipant: { gate: "removeParticipant", groupOnly: true },
leaveGroup: { gate: "leaveGroup", groupOnly: true },
sendAttachment: { gate: "sendAttachment" },
} as const;
type BlueBubblesActionSpecs = typeof BLUEBUBBLES_ACTIONS;
export const BLUEBUBBLES_ACTION_NAMES = Object.keys(BLUEBUBBLES_ACTIONS) as Array<
keyof BlueBubblesActionSpecs
>;

View File

@ -1,6 +1,6 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { collectBlueBubblesStatusIssues } from "openclaw/plugin-sdk/bluebubbles";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
@ -12,7 +12,6 @@ import {
buildProbeChannelStatusSummary,
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/channel-status";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
createComputedAccountStatusAdapter,
@ -48,6 +47,7 @@ import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js";
import { resolveBlueBubblesOutboundSessionRoute } from "./session-route.js";
import { blueBubblesSetupAdapter } from "./setup-core.js";
import { blueBubblesSetupWizard } from "./setup-surface.js";
import { collectBlueBubblesStatusIssues } from "./status-issues.js";
import {
extractHandleFromChatGuid,
inferBlueBubblesTargetChatType,

View File

@ -1,4 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
import {
registerSessionBindingAdapter,
resolveThreadBindingConversationIdFromBindingId,
@ -8,8 +9,7 @@ import {
type BindingTargetKind,
type SessionBindingAdapter,
type SessionBindingRecord,
} from "openclaw/plugin-sdk/conversation-runtime";
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
} from "openclaw/plugin-sdk/thread-bindings-runtime";
type BlueBubblesBindingTargetKind = "subagent" | "acp";

View File

@ -5,42 +5,45 @@ export {
readNumberParam,
readReactionParams,
readStringParam,
} from "openclaw/plugin-sdk/bluebubbles";
export type { HistoryEntry } from "openclaw/plugin-sdk/bluebubbles";
} from "openclaw/plugin-sdk/channel-actions";
export type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
export {
evictOldHistoryKeys,
recordPendingHistoryEntryIfEnabled,
} from "openclaw/plugin-sdk/bluebubbles";
export { resolveControlCommandGate } from "openclaw/plugin-sdk/bluebubbles";
export { logAckFailure, logInboundDrop, logTypingFailure } from "openclaw/plugin-sdk/bluebubbles";
export { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS } from "openclaw/plugin-sdk/bluebubbles";
export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/bluebubbles";
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/bluebubbles";
export { collectBlueBubblesStatusIssues } from "openclaw/plugin-sdk/bluebubbles";
} from "openclaw/plugin-sdk/reply-history";
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
export { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
export { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS } from "./actions-contract.js";
export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
export { collectBlueBubblesStatusIssues } from "./status-issues.js";
export type {
BaseProbeResult,
ChannelAccountSnapshot,
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/bluebubbles";
export type { ChannelPlugin } from "openclaw/plugin-sdk/bluebubbles";
export type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
export { parseFiniteNumber } from "openclaw/plugin-sdk/bluebubbles";
export type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/bluebubbles";
} from "openclaw/plugin-sdk/channel-contract";
export type {
ChannelPlugin,
OpenClawConfig,
PluginRuntime,
} from "openclaw/plugin-sdk/channel-core";
export { parseFiniteNumber } from "openclaw/plugin-sdk/infra-runtime";
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
export {
DM_GROUP_ACCESS_REASON,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "openclaw/plugin-sdk/bluebubbles";
export { readBooleanParam } from "openclaw/plugin-sdk/bluebubbles";
export { mapAllowFromEntries } from "openclaw/plugin-sdk/bluebubbles";
export { createChannelPairingController } from "openclaw/plugin-sdk/bluebubbles";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/bluebubbles";
export { resolveRequestUrl } from "openclaw/plugin-sdk/bluebubbles";
export { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/bluebubbles";
export { stripMarkdown } from "openclaw/plugin-sdk/bluebubbles";
export { extractToolSend } from "openclaw/plugin-sdk/bluebubbles";
} from "openclaw/plugin-sdk/channel-policy";
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
export { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
export { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
export { stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
export { extractToolSend } from "openclaw/plugin-sdk/tool-send";
export {
WEBHOOK_RATE_LIMIT_DEFAULTS,
createFixedWindowRateLimiter,
@ -50,7 +53,7 @@ export {
resolveRequestClientIp,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
} from "openclaw/plugin-sdk/bluebubbles";
} from "openclaw/plugin-sdk/webhook-ingress";
export { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
export {
evaluateSupplementalContextVisibility,

View File

@ -1,5 +1,5 @@
import { collectBlueBubblesStatusIssues } from "openclaw/plugin-sdk/bluebubbles";
import { describe, expect, it } from "vitest";
import { collectBlueBubblesStatusIssues } from "./status-issues.js";
describe("collectBlueBubblesStatusIssues", () => {
it("reports unconfigured enabled accounts", () => {

View File

@ -0,0 +1,103 @@
import { collectIssuesForEnabledAccounts } from "openclaw/plugin-sdk/status-helpers";
import type { ChannelAccountSnapshot } from "./runtime-api.js";
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 isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : 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[]) {
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.",
});
}
},
});
}

View File

@ -9,10 +9,9 @@ import type {
ChannelMessageActionAdapter,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/channel-targets";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
@ -28,6 +27,7 @@ import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/target-resolver-runtime";
import {
listDiscordAccountIds,
resolveDiscordAccount,
@ -51,10 +51,9 @@ import {
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
import {
createThreadBindingManager,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
} from "./monitor/thread-bindings.js";
} from "./monitor/thread-bindings.session-updates.js";
import {
looksLikeDiscordTargetId,
normalizeDiscordMessagingTarget,
@ -67,7 +66,7 @@ import { normalizeExplicitDiscordSessionKey } from "./session-key-normalization.
import { discordSetupAdapter } from "./setup-core.js";
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
import { collectDiscordStatusIssues } from "./status-issues.js";
import { parseDiscordTarget } from "./targets.js";
import { parseDiscordTarget } from "./target-parsing.js";
import { DiscordUiContainer } from "./ui.js";
type DiscordSendFn = typeof import("./send.js").sendMessageDiscord;
@ -94,6 +93,9 @@ const loadDiscordResolveChannelsModule = createLazyRuntimeModule(
() => import("./resolve-channels.js"),
);
const loadDiscordResolveUsersModule = createLazyRuntimeModule(() => import("./resolve-users.js"));
const loadDiscordThreadBindingsManagerModule = createLazyRuntimeModule(
() => import("./monitor/thread-bindings.manager.js"),
);
const require = createRequire(import.meta.url);
@ -546,8 +548,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
conversationBindings: {
supportsCurrentConversationBinding: true,
defaultTopLevelPlacement: "child",
createManager: ({ cfg, accountId }) =>
createThreadBindingManager({
createManager: async ({ cfg, accountId }) =>
(await loadDiscordThreadBindingsManagerModule()).createThreadBindingManager({
cfg,
accountId: accountId ?? undefined,
persist: false,

View File

@ -16,12 +16,12 @@ import {
type DiscordComponentBuildResult,
type DiscordComponentMessageSpec,
} from "./components.js";
import { parseAndResolveRecipient } from "./recipient-resolution.js";
import { loadOutboundMediaFromUrl } from "./runtime-api.js";
import { sendMessageDiscord } from "./send.outbound.js";
import {
buildDiscordSendError,
createDiscordClient,
parseAndResolveRecipient,
resolveChannelId,
resolveDiscordChannelType,
toDiscordFileBlob,

View File

@ -9,7 +9,6 @@ import {
import { PollLayoutType } from "discord-api-types/payloads/v10";
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
import { Routes, type APIChannel, type APIEmbed } from "discord-api-types/v10";
import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
import { extensionForMime } from "openclaw/plugin-sdk/media-runtime";
import {
@ -21,12 +20,10 @@ import type { ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload";
import type { RetryRunner } from "openclaw/plugin-sdk/retry-runtime";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { resolveDiscordAccount } from "./accounts.js";
import { chunkDiscordTextWithMode } from "./chunk.js";
import { createDiscordClient, resolveDiscordRest } from "./client.js";
import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
import { DiscordSendError } from "./send.types.js";
import { parseDiscordTarget, resolveDiscordTarget } from "./targets.js";
const DISCORD_TEXT_LIMIT = 2000;
const DISCORD_MAX_STICKERS = 3;
@ -40,7 +37,6 @@ type DiscordRequest = RetryRunner;
export type DiscordSendComponentFactory = (text: string) => TopLevelComponents[];
export type DiscordSendComponents = TopLevelComponents[] | DiscordSendComponentFactory;
export type DiscordSendEmbeds = Array<APIEmbed | Embed>;
type DiscordRecipient =
| {
kind: "user";
@ -63,63 +59,6 @@ function normalizeReactionEmoji(raw: string) {
return encodeURIComponent(identifier);
}
function parseRecipient(raw: string): DiscordRecipient {
const target = parseDiscordTarget(raw, {
defaultKind: "channel",
ambiguousMessage: `Ambiguous Discord recipient "${raw.trim()}". Use "user:${raw.trim()}" for DMs or "channel:${raw.trim()}" for channel messages.`,
});
if (!target) {
throw new Error("Recipient is required for Discord sends");
}
return { kind: target.kind, id: target.id };
}
/**
* Parse and resolve Discord recipient, including username lookup.
* This enables sending DMs by username (e.g., "john.doe") by querying
* the Discord directory to resolve usernames to user IDs.
*
* @param raw - The recipient string (username, ID, or known format)
* @param accountId - Discord account ID to use for directory lookup
* @returns Parsed DiscordRecipient with resolved user ID if applicable
*/
export async function parseAndResolveRecipient(
raw: string,
accountId?: string,
cfg?: OpenClawConfig,
): Promise<DiscordRecipient> {
const resolvedCfg = cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({ cfg: resolvedCfg, accountId });
// First try to resolve using directory lookup (handles usernames)
const trimmed = raw.trim();
const parseOptions = {
ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
};
const resolved = await resolveDiscordTarget(
raw,
{
cfg: resolvedCfg,
accountId: accountInfo.accountId,
},
parseOptions,
);
if (resolved) {
return { kind: resolved.kind, id: resolved.id };
}
// Fallback to standard parsing (for channels, etc.)
const parsed = parseDiscordTarget(raw, parseOptions);
if (!parsed) {
throw new Error("Recipient is required for Discord sends");
}
return { kind: parsed.kind, id: parsed.id };
}
function normalizeStickerIds(raw: string[]) {
const ids = raw.map((entry) => entry.trim()).filter(Boolean);
if (ids.length === 0) {
@ -512,7 +451,6 @@ export {
normalizeEmojiName,
normalizeReactionEmoji,
normalizeStickerIds,
parseRecipient,
resolveChannelId,
resolveDiscordRest,
sendDiscordMedia,

View File

@ -0,0 +1,111 @@
import type { DirectoryConfigParams } from "openclaw/plugin-sdk/directory-runtime";
import { buildMessagingTarget, type MessagingTarget } from "openclaw/plugin-sdk/messaging-targets";
import { resolveDiscordAccount } from "./accounts.js";
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
import { parseDiscordSendTarget } from "./send-target-parsing.js";
import { type DiscordTargetParseOptions } from "./target-parsing.js";
/**
* Resolve a Discord username to user ID using the directory lookup.
* This enables sending DMs by username instead of requiring explicit user IDs.
*/
export async function resolveDiscordTarget(
raw: string,
options: DirectoryConfigParams,
parseOptions: DiscordTargetParseOptions = {},
): Promise<MessagingTarget | undefined> {
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
const likelyUsername = isLikelyUsername(trimmed);
const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername;
// Parse directly if it's already a known format. Use a safe parse so ambiguous
// numeric targets don't throw when we still want to attempt username lookup.
const directParse = safeParseDiscordTarget(trimmed, parseOptions);
if (directParse && directParse.kind !== "channel" && !likelyUsername) {
return directParse;
}
if (!shouldLookup) {
return directParse ?? parseDiscordSendTarget(trimmed, parseOptions);
}
try {
const directoryEntries = await listDiscordDirectoryPeersLive({
...options,
query: trimmed,
limit: 1,
});
const match = directoryEntries[0];
if (match && match.kind === "user") {
const userId = match.id.replace(/^user:/, "");
const resolvedAccountId = resolveDiscordAccount({
cfg: options.cfg,
accountId: options.accountId,
}).accountId;
rememberDiscordDirectoryUser({
accountId: resolvedAccountId,
userId,
handles: [trimmed, match.name, match.handle],
});
return buildMessagingTarget("user", userId, trimmed);
}
} catch {
// Preserve legacy fallback behavior for channel names and direct ids.
}
return parseDiscordSendTarget(trimmed, parseOptions);
}
export async function parseAndResolveDiscordTarget(
raw: string,
options: DirectoryConfigParams,
parseOptions: DiscordTargetParseOptions = {},
): Promise<MessagingTarget> {
const resolved =
(await resolveDiscordTarget(raw, options, parseOptions)) ??
parseDiscordSendTarget(raw, parseOptions);
if (!resolved) {
throw new Error("Recipient is required for Discord sends");
}
return resolved;
}
function safeParseDiscordTarget(
input: string,
options: DiscordTargetParseOptions,
): MessagingTarget | undefined {
try {
return parseDiscordSendTarget(input, options);
} catch {
return undefined;
}
}
function isExplicitUserLookup(input: string, options: DiscordTargetParseOptions): boolean {
if (/^<@!?(\d+)>$/.test(input)) {
return true;
}
if (/^(user:|discord:)/.test(input)) {
return true;
}
if (input.startsWith("@")) {
return true;
}
if (/^\d+$/.test(input)) {
return options.defaultKind === "user";
}
return false;
}
function isLikelyUsername(input: string): boolean {
if (/^(user:|channel:|discord:|@|<@!?)|[\d]+$/.test(input)) {
return false;
}
return true;
}

View File

@ -7,10 +7,9 @@ import {
adaptScopedAccountAccessor,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/channel-targets";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
@ -31,6 +30,7 @@ import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/target-resolver-runtime";
import {
listEnabledSlackAccounts,
resolveDefaultSlackAccountId,
@ -71,7 +71,7 @@ import {
slackConfigAdapter,
SLACK_CHANNEL,
} from "./shared.js";
import { parseSlackTarget } from "./targets.js";
import { parseSlackTarget } from "./target-parsing.js";
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
const resolveSlackDmPolicy = createScopedDmSecurityResolver<ResolvedSlackAccount>({

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