From 667a54a4b73e79928bc92a034d2168ab71868fce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 07:39:14 +0100 Subject: [PATCH] refactor(plugins): narrow bundled channel core seams --- extensions/bluebubbles/src/actions-api.ts | 11 +- .../bluebubbles/src/actions-contract.ts | 19 + extensions/bluebubbles/src/channel.ts | 4 +- .../bluebubbles/src/conversation-bindings.ts | 4 +- extensions/bluebubbles/src/runtime-api.ts | 53 +-- .../bluebubbles/src/status-issues.test.ts | 2 +- extensions/bluebubbles/src/status-issues.ts | 103 +++++ extensions/discord/src/channel.ts | 16 +- extensions/discord/src/send.components.ts | 2 +- extensions/discord/src/send.shared.ts | 62 --- extensions/discord/src/target-resolver.ts | 111 ++++++ extensions/slack/src/channel.ts | 6 +- src/channels/chat-meta.ts | 95 +---- src/channels/plugins/bundled.ts | 82 +++- src/plugin-sdk/bluebubbles.ts | 98 +---- src/plugin-sdk/channel-core.ts | 377 ++++++++++++++++++ src/plugin-sdk/core.ts | 107 ++++- 17 files changed, 847 insertions(+), 305 deletions(-) create mode 100644 extensions/bluebubbles/src/actions-contract.ts create mode 100644 extensions/bluebubbles/src/status-issues.ts create mode 100644 extensions/discord/src/target-resolver.ts create mode 100644 src/plugin-sdk/channel-core.ts diff --git a/extensions/bluebubbles/src/actions-api.ts b/extensions/bluebubbles/src/actions-api.ts index 131164fbdad..0b7e09f6fd5 100644 --- a/extensions/bluebubbles/src/actions-api.ts +++ b/extensions/bluebubbles/src/actions-api.ts @@ -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"; diff --git a/extensions/bluebubbles/src/actions-contract.ts b/extensions/bluebubbles/src/actions-contract.ts new file mode 100644 index 00000000000..bf6a77e7898 --- /dev/null +++ b/extensions/bluebubbles/src/actions-contract.ts @@ -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 +>; diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 2ddb21b455a..58a82e439f8 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -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, diff --git a/extensions/bluebubbles/src/conversation-bindings.ts b/extensions/bluebubbles/src/conversation-bindings.ts index b4ccbdb0c43..7b444069a9a 100644 --- a/extensions/bluebubbles/src/conversation-bindings.ts +++ b/extensions/bluebubbles/src/conversation-bindings.ts @@ -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"; diff --git a/extensions/bluebubbles/src/runtime-api.ts b/extensions/bluebubbles/src/runtime-api.ts index 2467ad06e7b..23df82e0180 100644 --- a/extensions/bluebubbles/src/runtime-api.ts +++ b/extensions/bluebubbles/src/runtime-api.ts @@ -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, diff --git a/extensions/bluebubbles/src/status-issues.test.ts b/extensions/bluebubbles/src/status-issues.test.ts index 989d1ad5d53..1d1ed69c837 100644 --- a/extensions/bluebubbles/src/status-issues.test.ts +++ b/extensions/bluebubbles/src/status-issues.test.ts @@ -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", () => { diff --git a/extensions/bluebubbles/src/status-issues.ts b/extensions/bluebubbles/src/status-issues.ts new file mode 100644 index 00000000000..5771acdbf2a --- /dev/null +++ b/extensions/bluebubbles/src/status-issues.ts @@ -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 { + 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 --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.", + }); + } + }, + }); +} diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 768e29b4f24..93bf063c6af 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -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 conversationBindings: { supportsCurrentConversationBinding: true, defaultTopLevelPlacement: "child", - createManager: ({ cfg, accountId }) => - createThreadBindingManager({ + createManager: async ({ cfg, accountId }) => + (await loadDiscordThreadBindingsManagerModule()).createThreadBindingManager({ cfg, accountId: accountId ?? undefined, persist: false, diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index 54e7d47741b..c8479282706 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -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, diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index c95c2625e6e..f3b7ef9d6d8 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -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; - 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 { - 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, diff --git a/extensions/discord/src/target-resolver.ts b/extensions/discord/src/target-resolver.ts new file mode 100644 index 00000000000..417a1de4e0f --- /dev/null +++ b/extensions/discord/src/target-resolver.ts @@ -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 { + 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 { + 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; +} diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index e56c3915bec..dfe6819884b 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -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({ diff --git a/src/channels/chat-meta.ts b/src/channels/chat-meta.ts index 209449fb5a8..72a01aceb17 100644 --- a/src/channels/chat-meta.ts +++ b/src/channels/chat-meta.ts @@ -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(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 { - const entries = new Map(); - - 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; -} const CHAT_CHANNEL_META = buildChatChannelMetaById(); +export type { ChatChannelMeta }; + export function listChatChannels(): ChatChannelMeta[] { return CHAT_CHANNEL_ORDER.map((id) => CHAT_CHANNEL_META[id]); } diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index f7a8e97b17d..1be86ae6de9 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -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).filter( + ([key]) => key !== "default", + ); + const pluginCandidates = entries.filter( + ([key, candidate]) => + key.endsWith("Plugin") && + !!candidate && + typeof candidate === "object" && + "id" in (candidate as Record), + ); + 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>(); 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["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, diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index d95cc04379e..92f9a082818 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -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 --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"; diff --git a/src/plugin-sdk/channel-core.ts b/src/plugin-sdk/channel-core.ts new file mode 100644 index 00000000000..eaa4ee63c1d --- /dev/null +++ b/src/plugin-sdk/channel-core.ts @@ -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 +>[0]; + +type DefineChannelPluginEntryOptions = { + 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 = { + id: string; + name: string; + description: string; + configSchema: OpenClawPluginConfigSchema; + register: (api: OpenClawPluginApi) => void; + channelPlugin: TPlugin; + setChannelRuntime?: (runtime: PluginRuntime) => void; +}; + +type ChatChannelPluginBase = Omit< + ChannelPlugin, + "security" | "pairing" | "threading" | "outbound" +> & + Partial< + Pick< + ChannelPlugin, + "security" | "pairing" | "threading" | "outbound" + > + >; + +type ChatChannelSecurityOptions = { + dm: { + channelKey: string; + resolvePolicy: (account: TResolvedAccount) => string | null | undefined; + resolveAllowFrom: (account: TResolvedAccount) => Array | null | undefined; + resolveFallbackAccountId?: (account: TResolvedAccount) => string | null | undefined; + defaultPolicy?: string; + allowFromPathSuffix?: string; + policyPathSuffix?: string; + approveChannelId?: string; + approveHint?: string; + normalizeEntry?: (raw: string) => string; + }; + collectWarnings?: ChannelSecurityAdapter["collectWarnings"]; + collectAuditFindings?: ChannelSecurityAdapter["collectAuditFindings"]; +}; + +type ChatChannelPairingOptions = { + text: { + idLabel: string; + message: string; + normalizeAllowEntry?: ChannelPairingAdapter["normalizeAllowEntry"]; + notify: ( + params: Parameters>[0] & { + message: string; + }, + ) => Promise | void; + }; +}; + +type ChatChannelThreadingReplyModeOptions = + | { topLevelReplyToMode: string } + | { + scopedAccountReplyToMode: { + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TResolvedAccount; + resolveReplyToMode: ( + account: TResolvedAccount, + chatType?: string | null, + ) => ReplyToMode | null | undefined; + fallback?: ReplyToMode; + }; + } + | { + resolveReplyToMode: NonNullable; + }; + +type ChatChannelThreadingOptions = + ChatChannelThreadingReplyModeOptions & + Omit; + +type ChatChannelAttachedOutboundOptions = { + base: Omit; + attachedResults: { + channel: string; + sendText?: ( + ctx: Parameters>[0], + ) => MaybePromise>; + sendMedia?: ( + ctx: Parameters>[0], + ) => MaybePromise>; + sendPoll?: ( + ctx: Parameters>[0], + ) => MaybePromise>; + }; +}; + +type MaybePromise = T | Promise; + +function createInlineTextPairingAdapter(params: { + idLabel: string; + message: string; + normalizeAllowEntry?: ChannelPairingAdapter["normalizeAllowEntry"]; + notify: ( + params: Parameters>[0] & { + message: string; + }, + ) => Promise | 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>[0]) => ({ + channel: params.channel, + ...(await params.sendText!(ctx)), + }) + : undefined, + sendMedia: params.sendMedia + ? async (ctx: Parameters>[0]) => ({ + channel: params.channel, + ...(await params.sendMedia!(ctx)), + }) + : undefined, + sendPoll: params.sendPoll + ? async (ctx: Parameters>[0]) => ({ + channel: params.channel, + ...(await params.sendPoll!(ctx)), + }) + : undefined, + } satisfies Pick; +} + +function resolveChatChannelSecurity( + security: + | ChannelSecurityAdapter + | ChatChannelSecurityOptions + | undefined, +): ChannelSecurityAdapter | 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( + threading: ChannelThreadingAdapter | ChatChannelThreadingOptions | 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( + 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({ + id, + name, + description, + plugin, + configSchema = emptyPluginConfigSchema, + setRuntime, + registerCliMetadata, + registerFull, +}: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry { + 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(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; + security?: + | ChannelSecurityAdapter + | ChatChannelSecurityOptions; + pairing?: ChannelPairingAdapter | ChatChannelPairingOptions; + threading?: ChannelThreadingAdapter | ChatChannelThreadingOptions; + outbound?: ChannelOutboundAdapter | ChatChannelAttachedOutboundOptions; +}): ChannelPlugin { + 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; +} diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 756197faa05..8ccc9960d8c 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -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 >[0]; +var cachedSdkChatChannelMeta: ReturnType | undefined; +var cachedSdkChatChannelIdSet: Set | undefined; + +function getSdkChatChannelIdSet(): Set { + 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 { + const entries = new Map(); + 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; +} + +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( return { id: params.id, meta: { - ...getChatChannelMeta(params.id as Parameters[0]), + ...resolveSdkChatChannelMeta(params.id), ...params.meta, }, ...(params.setupWizard ? { setupWizard: params.setupWizard } : {}),