diff --git a/extensions/bluebubbles/api.ts b/extensions/bluebubbles/api.ts index c4ed99e6c6e..c5cd3e10a75 100644 --- a/extensions/bluebubbles/api.ts +++ b/extensions/bluebubbles/api.ts @@ -1,6 +1,6 @@ -export { bluebubblesPlugin } from "./src/channel.js"; export * from "./src/conversation-id.js"; export * from "./src/conversation-bindings.js"; +export { collectBlueBubblesStatusIssues } from "./src/status-issues.js"; export { resolveBlueBubblesGroupRequireMention, resolveBlueBubblesGroupToolPolicy, diff --git a/extensions/bluebubbles/index.ts b/extensions/bluebubbles/index.ts index 3e4ab2b4ff8..1372f2958f9 100644 --- a/extensions/bluebubbles/index.ts +++ b/extensions/bluebubbles/index.ts @@ -1,4 +1,4 @@ -import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { bluebubblesPlugin } from "./src/channel.js"; import { setBlueBubblesRuntime } from "./src/runtime.js"; diff --git a/extensions/bluebubbles/setup-entry.ts b/extensions/bluebubbles/setup-entry.ts index 73260ef8316..a5074b91f59 100644 --- a/extensions/bluebubbles/setup-entry.ts +++ b/extensions/bluebubbles/setup-entry.ts @@ -1,4 +1,4 @@ -import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; +import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { bluebubblesSetupPlugin } from "./src/channel.setup.js"; export { bluebubblesSetupPlugin } from "./src/channel.setup.js"; diff --git a/extensions/bluebubbles/src/monitor-processing-api.ts b/extensions/bluebubbles/src/monitor-processing-api.ts index 924454f1bfe..7b7eb3e0d7f 100644 --- a/extensions/bluebubbles/src/monitor-processing-api.ts +++ b/extensions/bluebubbles/src/monitor-processing-api.ts @@ -1,4 +1,4 @@ -export { resolveAckReaction } from "./runtime-api.js"; +export { resolveAckReaction } from "openclaw/plugin-sdk/channel-feedback"; export { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; export { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; diff --git a/extensions/bluebubbles/src/session-route.ts b/extensions/bluebubbles/src/session-route.ts index a889887a4bd..f84e39a2bab 100644 --- a/extensions/bluebubbles/src/session-route.ts +++ b/extensions/bluebubbles/src/session-route.ts @@ -2,7 +2,7 @@ import { buildChannelOutboundSessionRoute, stripChannelTargetPrefix, type ChannelOutboundSessionRouteParams, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/channel-core"; import { parseBlueBubblesTarget } from "./targets.js"; export function resolveBlueBubblesOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index 7328b96015d..df4c3d2ed32 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -1,4 +1,4 @@ -import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { discordPlugin } from "./src/channel.js"; import { setDiscordRuntime } from "./src/runtime.js"; diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts index e2c4689ed39..66e31c46778 100644 --- a/extensions/discord/setup-entry.ts +++ b/extensions/discord/setup-entry.ts @@ -1,4 +1,4 @@ -import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; +import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { discordSetupPlugin } from "./src/channel.setup.js"; export { discordSetupPlugin } from "./src/channel.setup.js"; diff --git a/extensions/discord/src/channel-api.ts b/extensions/discord/src/channel-api.ts index 7d16c3dabfa..4664eb4a3b2 100644 --- a/extensions/discord/src/channel-api.ts +++ b/extensions/discord/src/channel-api.ts @@ -6,6 +6,24 @@ export { resolveConfiguredFromCredentialStatuses, } from "openclaw/plugin-sdk/channel-status"; export { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -export { getChatChannelMeta } from "openclaw/plugin-sdk/core"; -export type { ChannelPlugin } from "openclaw/plugin-sdk/core"; +export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; + +const DISCORD_CHANNEL_META = { + id: "discord", + label: "Discord", + selectionLabel: "Discord (Bot API)", + detailLabel: "Discord Bot", + docsPath: "/channels/discord", + docsLabel: "discord", + blurb: "very well supported right now.", + systemImage: "bubble.left.and.bubble.right", + markdownCapable: true, +} as const; + +export function getChatChannelMeta(id: string) { + if (id !== DISCORD_CHANNEL_META.id) { + throw new Error(`Unsupported Discord channel meta lookup: ${id}`); + } + return DISCORD_CHANNEL_META; +} diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index f738d5d4d55..f65df865bd7 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -1,4 +1,4 @@ -import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/core"; +import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core"; export const discordChannelConfigUiHints = { "": { diff --git a/extensions/discord/src/exec-approvals.ts b/extensions/discord/src/exec-approvals.ts index 056b8a58417..3b1b0ef3096 100644 --- a/extensions/discord/src/exec-approvals.ts +++ b/extensions/discord/src/exec-approvals.ts @@ -5,7 +5,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { resolveDiscordAccount } from "./accounts.js"; -import { parseDiscordTarget } from "./targets.js"; +import { parseDiscordTarget } from "./target-parsing.js"; function normalizeDiscordApproverId(value: string): string | undefined { const trimmed = value.trim(); diff --git a/extensions/discord/src/group-policy.ts b/extensions/discord/src/group-policy.ts index f1d19d98ef3..4357c95ef38 100644 --- a/extensions/discord/src/group-policy.ts +++ b/extensions/discord/src/group-policy.ts @@ -4,7 +4,7 @@ import { type GroupToolPolicyBySenderConfig, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; -import { normalizeAtHashSlug } from "openclaw/plugin-sdk/core"; +import { normalizeAtHashSlug } from "openclaw/plugin-sdk/string-normalization-runtime"; import type { DiscordConfig } from "./runtime-api.js"; function normalizeDiscordSlug(value?: string | null) { diff --git a/extensions/discord/src/monitor/thread-bindings.session-updates.ts b/extensions/discord/src/monitor/thread-bindings.session-updates.ts new file mode 100644 index 00000000000..09945f5efe0 --- /dev/null +++ b/extensions/discord/src/monitor/thread-bindings.session-updates.ts @@ -0,0 +1,88 @@ +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { + BINDINGS_BY_THREAD_ID, + ensureBindingsLoaded, + resolveBindingIdsForSession, + saveBindingsToDisk, + setBindingRecord, + shouldPersistBindingMutations, +} from "./thread-bindings.state.js"; +import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js"; + +function normalizeNonNegativeMs(raw: number): number { + if (!Number.isFinite(raw)) { + return 0; + } + return Math.max(0, Math.floor(raw)); +} + +function resolveBindingIdsForTargetSession(params: { + targetSessionKey: string; + accountId?: string; + targetKind?: ThreadBindingTargetKind; +}) { + ensureBindingsLoaded(); + const targetSessionKey = params.targetSessionKey.trim(); + if (!targetSessionKey) { + return []; + } + const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined; + return resolveBindingIdsForSession({ + targetSessionKey, + accountId, + targetKind: params.targetKind, + }); +} + +function updateBindingsForTargetSession( + ids: string[], + update: (existing: ThreadBindingRecord, now: number) => ThreadBindingRecord, +) { + if (ids.length === 0) { + return []; + } + const now = Date.now(); + const updated: ThreadBindingRecord[] = []; + for (const bindingKey of ids) { + const existing = BINDINGS_BY_THREAD_ID.get(bindingKey); + if (!existing) { + continue; + } + const nextRecord = update(existing, now); + setBindingRecord(nextRecord); + updated.push(nextRecord); + } + if (updated.length > 0 && shouldPersistBindingMutations()) { + saveBindingsToDisk({ force: true }); + } + return updated; +} + +export function setThreadBindingIdleTimeoutBySessionKey(params: { + targetSessionKey: string; + accountId?: string; + idleTimeoutMs: number; +}): ThreadBindingRecord[] { + const ids = resolveBindingIdsForTargetSession(params); + const idleTimeoutMs = normalizeNonNegativeMs(params.idleTimeoutMs); + return updateBindingsForTargetSession(ids, (existing, now) => ({ + ...existing, + idleTimeoutMs, + lastActivityAt: now, + })); +} + +export function setThreadBindingMaxAgeBySessionKey(params: { + targetSessionKey: string; + accountId?: string; + maxAgeMs: number; +}): ThreadBindingRecord[] { + const ids = resolveBindingIdsForTargetSession(params); + const maxAgeMs = normalizeNonNegativeMs(params.maxAgeMs); + return updateBindingsForTargetSession(ids, (existing, now) => ({ + ...existing, + maxAgeMs, + boundAt: now, + lastActivityAt: now, + })); +} diff --git a/extensions/discord/src/normalize.ts b/extensions/discord/src/normalize.ts index 231cba8e5dc..88830ad7b69 100644 --- a/extensions/discord/src/normalize.ts +++ b/extensions/discord/src/normalize.ts @@ -1,4 +1,4 @@ -import { parseDiscordTarget } from "./targets.js"; +import { parseDiscordTarget } from "./target-parsing.js"; export function normalizeDiscordMessagingTarget(raw: string): string | undefined { // Default bare IDs to channels so routing is stable across tool actions. diff --git a/extensions/discord/src/outbound-session-route.ts b/extensions/discord/src/outbound-session-route.ts index 9f527b8a793..6a64ddb3ea6 100644 --- a/extensions/discord/src/outbound-session-route.ts +++ b/extensions/discord/src/outbound-session-route.ts @@ -5,7 +5,7 @@ import { type RoutePeer, } from "openclaw/plugin-sdk/routing"; import type { OpenClawConfig } from "./runtime-api.js"; -import { parseDiscordTarget } from "./targets.js"; +import { parseDiscordTarget } from "./target-parsing.js"; export type ResolveDiscordOutboundSessionRouteParams = { cfg: OpenClawConfig; diff --git a/extensions/discord/src/recipient-resolution.ts b/extensions/discord/src/recipient-resolution.ts new file mode 100644 index 00000000000..4f366c20eab --- /dev/null +++ b/extensions/discord/src/recipient-resolution.ts @@ -0,0 +1,35 @@ +import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveDiscordAccount } from "./accounts.js"; +import { parseAndResolveDiscordTarget } from "./target-resolver.js"; + +type DiscordRecipient = + | { + kind: "user"; + id: string; + } + | { + kind: "channel"; + id: string; + }; + +export async function parseAndResolveRecipient( + raw: string, + accountId?: string, + cfg?: OpenClawConfig, +): Promise { + const resolvedCfg = cfg ?? loadConfig(); + const accountInfo = resolveDiscordAccount({ cfg: resolvedCfg, accountId }); + 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 parseAndResolveDiscordTarget( + raw, + { + cfg: resolvedCfg, + accountId: accountInfo.accountId, + }, + parseOptions, + ); + return { kind: resolved.kind, id: resolved.id }; +} diff --git a/extensions/discord/src/runtime-api.ts b/extensions/discord/src/runtime-api.ts index 192e7bd48d0..daf217bbe4f 100644 --- a/extensions/discord/src/runtime-api.ts +++ b/extensions/discord/src/runtime-api.ts @@ -11,7 +11,11 @@ export type { ChannelMessageActionContext, ChannelMessageActionName, } from "openclaw/plugin-sdk/channel-contract"; -export type { ChannelPlugin, OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/core"; +export type { + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "openclaw/plugin-sdk/channel-plugin-common"; export type { DiscordAccountConfig, DiscordActionConfig, @@ -48,8 +52,7 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco export { emptyPluginConfigSchema, formatPairingApproveHint, - getChatChannelMeta, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/channel-plugin-common"; export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; export { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; export { @@ -57,4 +60,5 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/secret-input"; +export { getChatChannelMeta } from "./channel-api.js"; export { resolveDiscordOutboundSessionRoute } from "./outbound-session-route.js"; diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 0ab6e8e6d2c..0aa5f660eea 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -1,4 +1,4 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import type { PluginRuntime } from "openclaw/plugin-sdk/channel-core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; type DiscordChannelRuntime = { diff --git a/extensions/discord/src/send-target-parsing.ts b/extensions/discord/src/send-target-parsing.ts new file mode 100644 index 00000000000..582fda5d246 --- /dev/null +++ b/extensions/discord/src/send-target-parsing.ts @@ -0,0 +1,44 @@ +import { + buildMessagingTarget, + type MessagingTarget, + type MessagingTargetParseOptions, +} from "openclaw/plugin-sdk/messaging-targets"; +import { parseMentionPrefixOrAtUserTarget } from "openclaw/plugin-sdk/messaging-targets"; + +export type SendDiscordTarget = MessagingTarget; + +export type SendDiscordTargetParseOptions = MessagingTargetParseOptions; + +export function parseDiscordSendTarget( + raw: string, + options: SendDiscordTargetParseOptions = {}, +): SendDiscordTarget | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + const userTarget = parseMentionPrefixOrAtUserTarget({ + raw: trimmed, + mentionPattern: /^<@!?(\d+)>$/, + prefixes: [ + { prefix: "user:", kind: "user" }, + { prefix: "channel:", kind: "channel" }, + { prefix: "discord:", kind: "user" }, + ], + atUserPattern: /^\d+$/, + atUserErrorMessage: "Discord DMs require a user id (use user: or a <@id> mention)", + }); + if (userTarget) { + return userTarget; + } + if (/^\d+$/.test(trimmed)) { + if (options.defaultKind) { + return buildMessagingTarget(options.defaultKind, trimmed, trimmed); + } + throw new Error( + options.ambiguousMessage ?? + `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`, + ); + } + return buildMessagingTarget("channel", trimmed, trimmed); +} diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index 6d17d5d7212..c0e8f387d5a 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -18,6 +18,7 @@ import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; import { resolveDiscordClientAccountContext } from "./client.js"; import { rewriteDiscordKnownMentions } from "./mentions.js"; +import { parseAndResolveRecipient } from "./recipient-resolution.js"; import { buildDiscordMessagePayload, buildDiscordSendError, @@ -25,7 +26,6 @@ import { createDiscordClient, normalizeDiscordPollInput, normalizeStickerIds, - parseAndResolveRecipient, resolveChannelId, resolveDiscordChannelType, resolveDiscordSendComponents, diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 4e32613da36..8c92f19991f 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -2,7 +2,6 @@ import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers"; import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, @@ -53,7 +52,7 @@ export function createDiscordPluginBase(params: { | "config" | "setup" > { - return createChannelPluginBase({ + return { id: DISCORD_CHANNEL, setupWizard: discordSetupWizard, meta: { ...getChatChannelMeta(DISCORD_CHANNEL) }, @@ -90,7 +89,7 @@ export function createDiscordPluginBase(params: { }), }, setup: params.setup, - }) as Pick< + } as Pick< ChannelPlugin, | "id" | "meta" diff --git a/extensions/discord/src/target-parsing.ts b/extensions/discord/src/target-parsing.ts new file mode 100644 index 00000000000..a56d7f8573c --- /dev/null +++ b/extensions/discord/src/target-parsing.ts @@ -0,0 +1,53 @@ +import { + buildMessagingTarget, + parseMentionPrefixOrAtUserTarget, + requireTargetKind, + type MessagingTarget, + type MessagingTargetKind, + type MessagingTargetParseOptions, +} from "openclaw/plugin-sdk/messaging-targets"; + +export type DiscordTargetKind = MessagingTargetKind; + +export type DiscordTarget = MessagingTarget; + +export type DiscordTargetParseOptions = MessagingTargetParseOptions; + +export function parseDiscordTarget( + raw: string, + options: DiscordTargetParseOptions = {}, +): DiscordTarget | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + const userTarget = parseMentionPrefixOrAtUserTarget({ + raw: trimmed, + mentionPattern: /^<@!?(\d+)>$/, + prefixes: [ + { prefix: "user:", kind: "user" }, + { prefix: "channel:", kind: "channel" }, + { prefix: "discord:", kind: "user" }, + ], + atUserPattern: /^\d+$/, + atUserErrorMessage: "Discord DMs require a user id (use user: or a <@id> mention)", + }); + if (userTarget) { + return userTarget; + } + if (/^\d+$/.test(trimmed)) { + if (options.defaultKind) { + return buildMessagingTarget(options.defaultKind, trimmed, trimmed); + } + throw new Error( + options.ambiguousMessage ?? + `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`, + ); + } + return buildMessagingTarget("channel", trimmed, trimmed); +} + +export function resolveDiscordChannelId(raw: string): string { + const target = parseDiscordTarget(raw, { defaultKind: "channel" }); + return requireTargetKind({ platform: "Discord", target, kind: "channel" }); +} diff --git a/extensions/discord/src/targets.ts b/extensions/discord/src/targets.ts index acaa7b75da6..43491ca88b0 100644 --- a/extensions/discord/src/targets.ts +++ b/extensions/discord/src/targets.ts @@ -1,162 +1,12 @@ import { - buildMessagingTarget, - parseMentionPrefixOrAtUserTarget, - requireTargetKind, - type MessagingTarget, - type MessagingTargetKind, - type MessagingTargetParseOptions, -} from "openclaw/plugin-sdk/channel-targets"; -import type { DirectoryConfigParams } from "openclaw/plugin-sdk/directory-runtime"; -import { resolveDiscordAccount } from "./accounts.js"; -import { rememberDiscordDirectoryUser } from "./directory-cache.js"; -import { listDiscordDirectoryPeersLive } from "./directory-live.js"; + parseDiscordTarget, + type DiscordTarget, + type DiscordTargetKind, + type DiscordTargetParseOptions, + resolveDiscordChannelId, +} from "./target-parsing.js"; +import { resolveDiscordTarget } from "./target-resolver.js"; -export type DiscordTargetKind = MessagingTargetKind; - -export type DiscordTarget = MessagingTarget; - -type DiscordTargetParseOptions = MessagingTargetParseOptions; - -export function parseDiscordTarget( - raw: string, - options: DiscordTargetParseOptions = {}, -): DiscordTarget | undefined { - const trimmed = raw.trim(); - if (!trimmed) { - return undefined; - } - const userTarget = parseMentionPrefixOrAtUserTarget({ - raw: trimmed, - mentionPattern: /^<@!?(\d+)>$/, - prefixes: [ - { prefix: "user:", kind: "user" }, - { prefix: "channel:", kind: "channel" }, - { prefix: "discord:", kind: "user" }, - ], - atUserPattern: /^\d+$/, - atUserErrorMessage: "Discord DMs require a user id (use user: or a <@id> mention)", - }); - if (userTarget) { - return userTarget; - } - if (/^\d+$/.test(trimmed)) { - if (options.defaultKind) { - return buildMessagingTarget(options.defaultKind, trimmed, trimmed); - } - throw new Error( - options.ambiguousMessage ?? - `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`, - ); - } - return buildMessagingTarget("channel", trimmed, trimmed); -} - -export function resolveDiscordChannelId(raw: string): string { - const target = parseDiscordTarget(raw, { defaultKind: "channel" }); - return requireTargetKind({ platform: "Discord", target, kind: "channel" }); -} - -/** - * Resolve a Discord username to user ID using the directory lookup. - * This enables sending DMs by username instead of requiring explicit user IDs. - * - * @param raw - The username or raw target string (e.g., "john.doe") - * @param options - Directory configuration params (cfg, accountId, limit) - * @param parseOptions - Messaging target parsing options (defaults, ambiguity message) - * @returns Parsed MessagingTarget with user ID, or undefined if not found - */ -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 ?? parseDiscordTarget(trimmed, parseOptions); - } - - // Try to resolve as a username via directory lookup - try { - const directoryEntries = await listDiscordDirectoryPeersLive({ - ...options, - query: trimmed, - limit: 1, - }); - - const match = directoryEntries[0]; - if (match && match.kind === "user") { - // Extract user ID from the directory entry (format: "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 { - // Directory lookup failed - fall through to parse as-is - // This preserves existing behavior for channel names - } - - // Fallback to original parsing (for channels, etc.) - return parseDiscordTarget(trimmed, parseOptions); -} - -function safeParseDiscordTarget( - input: string, - options: DiscordTargetParseOptions, -): MessagingTarget | undefined { - try { - return parseDiscordTarget(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; -} - -/** - * Check if a string looks like a Discord username (not a mention, prefix, or ID). - * Usernames typically don't start with special characters except underscore. - */ -function isLikelyUsername(input: string): boolean { - // Skip if it's already a known format - if (/^(user:|channel:|discord:|@|<@!?)|[\d]+$/.test(input)) { - return false; - } - // Likely a username if it doesn't match known patterns - return true; -} +export { parseDiscordTarget, resolveDiscordChannelId }; +export type { DiscordTarget, DiscordTargetKind, DiscordTargetParseOptions }; +export { resolveDiscordTarget }; diff --git a/extensions/signal/index.ts b/extensions/signal/index.ts index f18a7041b53..b31a0f0980c 100644 --- a/extensions/signal/index.ts +++ b/extensions/signal/index.ts @@ -1,4 +1,4 @@ -import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { signalPlugin } from "./src/channel.js"; import { setSignalRuntime } from "./src/runtime.js"; diff --git a/extensions/signal/setup-entry.ts b/extensions/signal/setup-entry.ts index 11930cbba37..ef1016b454a 100644 --- a/extensions/signal/setup-entry.ts +++ b/extensions/signal/setup-entry.ts @@ -1,4 +1,4 @@ -import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; +import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { signalSetupPlugin } from "./src/channel.setup.js"; export { signalSetupPlugin } from "./src/channel.setup.js"; diff --git a/extensions/slack/index.ts b/extensions/slack/index.ts index 924e6bc50eb..8d94d42b2f6 100644 --- a/extensions/slack/index.ts +++ b/extensions/slack/index.ts @@ -1,4 +1,4 @@ -import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { slackPlugin } from "./src/channel.js"; import { registerSlackPluginHttpRoutes } from "./src/http/plugin-routes.js"; import { setSlackRuntime } from "./src/runtime.js"; diff --git a/extensions/slack/setup-entry.ts b/extensions/slack/setup-entry.ts index 2600e593267..146157e103f 100644 --- a/extensions/slack/setup-entry.ts +++ b/extensions/slack/setup-entry.ts @@ -1,4 +1,4 @@ -import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; +import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { slackSetupPlugin } from "./src/channel.setup.js"; export { slackSetupPlugin } from "./src/channel.setup.js"; diff --git a/extensions/slack/src/channel-api.ts b/extensions/slack/src/channel-api.ts index 3c5f30e1f51..1a20abe3d99 100644 --- a/extensions/slack/src/channel-api.ts +++ b/extensions/slack/src/channel-api.ts @@ -4,7 +4,24 @@ export { projectCredentialSnapshotFields, resolveConfiguredFromRequiredCredentialStatuses, } from "openclaw/plugin-sdk/channel-status"; -export type { ChannelPlugin } from "openclaw/plugin-sdk/core"; -export { getChatChannelMeta } from "openclaw/plugin-sdk/core"; -export type { OpenClawConfig } from "openclaw/plugin-sdk/core"; -export { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./targets.js"; +export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +export { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./target-parsing.js"; + +const SLACK_CHANNEL_META = { + id: "slack", + label: "Slack", + selectionLabel: "Slack", + docsPath: "/channels/slack", + docsLabel: "slack", + blurb: "supports bot + app tokens, channels, threads, and interactive replies.", + systemImage: "number.square", + markdownCapable: true, +} as const; + +export function getChatChannelMeta(id: string) { + if (id !== SLACK_CHANNEL_META.id) { + throw new Error(`Unsupported Slack channel meta lookup: ${id}`); + } + return SLACK_CHANNEL_META; +} diff --git a/extensions/slack/src/config-ui-hints.ts b/extensions/slack/src/config-ui-hints.ts index d2aee4064b5..e4f50bdab32 100644 --- a/extensions/slack/src/config-ui-hints.ts +++ b/extensions/slack/src/config-ui-hints.ts @@ -1,4 +1,4 @@ -import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/core"; +import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core"; export const slackChannelConfigUiHints = { "": { diff --git a/extensions/slack/src/group-policy.ts b/extensions/slack/src/group-policy.ts index b6464381034..d4ab891c551 100644 --- a/extensions/slack/src/group-policy.ts +++ b/extensions/slack/src/group-policy.ts @@ -5,7 +5,7 @@ import { type GroupToolPolicyBySenderConfig, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; -import { normalizeHyphenSlug } from "openclaw/plugin-sdk/core"; +import { normalizeHyphenSlug } from "openclaw/plugin-sdk/string-normalization-runtime"; import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js"; type SlackChannelPolicyEntry = { diff --git a/extensions/slack/src/runtime-api.ts b/extensions/slack/src/runtime-api.ts index a86a2291d6a..f6ee16d23ab 100644 --- a/extensions/slack/src/runtime-api.ts +++ b/extensions/slack/src/runtime-api.ts @@ -9,18 +9,18 @@ export type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-co export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; export type { ChannelPlugin, - OpenClawConfig, OpenClawPluginApi, PluginRuntime, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/channel-plugin-common"; +export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; export type { SlackAccountConfig } from "openclaw/plugin-sdk/config-runtime"; export { emptyPluginConfigSchema, formatPairingApproveHint, - getChatChannelMeta, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/channel-plugin-common"; export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; -export { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./targets.js"; +export { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./target-parsing.js"; +export { getChatChannelMeta } from "./channel-api.js"; export { createActionGate, imageResultFromFile, diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index e0241e6c3a9..1c668df7484 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -1,4 +1,4 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import type { PluginRuntime } from "openclaw/plugin-sdk/channel-core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; type SlackChannelRuntime = { diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 3c7fe4271a5..aa55cf779d8 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -4,7 +4,6 @@ import { adaptScopedAccountAccessor, createScopedChannelConfigAdapter, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { formatDocsLink, hasConfiguredSecretInput, @@ -178,7 +177,7 @@ export function createSlackPluginBase(params: { | "config" | "setup" > { - return createChannelPluginBase({ + return { id: SLACK_CHANNEL, meta: { ...getChatChannelMeta(SLACK_CHANNEL), @@ -240,7 +239,7 @@ export function createSlackPluginBase(params: { }), }, setup: params.setup, - }) as Pick< + } as Pick< ChannelPlugin, | "id" | "meta" diff --git a/extensions/slack/src/target-parsing.ts b/extensions/slack/src/target-parsing.ts new file mode 100644 index 00000000000..7f7720fa336 --- /dev/null +++ b/extensions/slack/src/target-parsing.ts @@ -0,0 +1,81 @@ +import { + buildMessagingTarget, + ensureTargetId, + parseMentionPrefixOrAtUserTarget, + requireTargetKind, + type MessagingTarget, + type MessagingTargetKind, + type MessagingTargetParseOptions, +} from "openclaw/plugin-sdk/messaging-targets"; + +export type SlackTargetKind = MessagingTargetKind; + +export type SlackTarget = MessagingTarget; + +export type SlackTargetParseOptions = MessagingTargetParseOptions; + +export function parseSlackTarget( + raw: string, + options: SlackTargetParseOptions = {}, +): SlackTarget | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + const userTarget = parseMentionPrefixOrAtUserTarget({ + raw: trimmed, + mentionPattern: /^<@([A-Z0-9]+)>$/i, + prefixes: [ + { prefix: "user:", kind: "user" }, + { prefix: "channel:", kind: "channel" }, + { prefix: "slack:", kind: "user" }, + ], + atUserPattern: /^[A-Z0-9]+$/i, + atUserErrorMessage: "Slack DMs require a user id (use user: or <@id>)", + }); + if (userTarget) { + return userTarget; + } + if (trimmed.startsWith("#")) { + const candidate = trimmed.slice(1).trim(); + const id = ensureTargetId({ + candidate, + pattern: /^[A-Z0-9]+$/i, + errorMessage: "Slack channels require a channel id (use channel:)", + }); + return buildMessagingTarget("channel", id, trimmed); + } + if (options.defaultKind) { + return buildMessagingTarget(options.defaultKind, trimmed, trimmed); + } + return buildMessagingTarget("channel", trimmed, trimmed); +} + +export function resolveSlackChannelId(raw: string): string { + const target = parseSlackTarget(raw, { defaultKind: "channel" }); + return requireTargetKind({ platform: "Slack", target, kind: "channel" }); +} + +export function normalizeSlackMessagingTarget(raw: string): string | undefined { + return parseSlackTarget(raw, { defaultKind: "channel" })?.normalized; +} + +export function looksLikeSlackTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + if (/^<@([A-Z0-9]+)>$/i.test(trimmed)) { + return true; + } + if (/^(user|channel):/i.test(trimmed)) { + return true; + } + if (/^slack:/i.test(trimmed)) { + return true; + } + if (/^[@#]/.test(trimmed)) { + return true; + } + return /^[CUWGD][A-Z0-9]{8,}$/i.test(trimmed); +} diff --git a/extensions/slack/src/targets.ts b/extensions/slack/src/targets.ts index bdafb120c05..5b22766ce8b 100644 --- a/extensions/slack/src/targets.ts +++ b/extensions/slack/src/targets.ts @@ -1,81 +1,7 @@ -import { - buildMessagingTarget, - ensureTargetId, - parseMentionPrefixOrAtUserTarget, - requireTargetKind, - type MessagingTarget, - type MessagingTargetKind, - type MessagingTargetParseOptions, -} from "openclaw/plugin-sdk/channel-targets"; - -export type SlackTargetKind = MessagingTargetKind; - -export type SlackTarget = MessagingTarget; - -type SlackTargetParseOptions = MessagingTargetParseOptions; - -export function parseSlackTarget( - raw: string, - options: SlackTargetParseOptions = {}, -): SlackTarget | undefined { - const trimmed = raw.trim(); - if (!trimmed) { - return undefined; - } - const userTarget = parseMentionPrefixOrAtUserTarget({ - raw: trimmed, - mentionPattern: /^<@([A-Z0-9]+)>$/i, - prefixes: [ - { prefix: "user:", kind: "user" }, - { prefix: "channel:", kind: "channel" }, - { prefix: "slack:", kind: "user" }, - ], - atUserPattern: /^[A-Z0-9]+$/i, - atUserErrorMessage: "Slack DMs require a user id (use user: or <@id>)", - }); - if (userTarget) { - return userTarget; - } - if (trimmed.startsWith("#")) { - const candidate = trimmed.slice(1).trim(); - const id = ensureTargetId({ - candidate, - pattern: /^[A-Z0-9]+$/i, - errorMessage: "Slack channels require a channel id (use channel:)", - }); - return buildMessagingTarget("channel", id, trimmed); - } - if (options.defaultKind) { - return buildMessagingTarget(options.defaultKind, trimmed, trimmed); - } - return buildMessagingTarget("channel", trimmed, trimmed); -} - -export function resolveSlackChannelId(raw: string): string { - const target = parseSlackTarget(raw, { defaultKind: "channel" }); - return requireTargetKind({ platform: "Slack", target, kind: "channel" }); -} - -export function normalizeSlackMessagingTarget(raw: string): string | undefined { - return parseSlackTarget(raw, { defaultKind: "channel" })?.normalized; -} - -export function looksLikeSlackTargetId(raw: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - if (/^<@([A-Z0-9]+)>$/i.test(trimmed)) { - return true; - } - if (/^(user|channel):/i.test(trimmed)) { - return true; - } - if (/^slack:/i.test(trimmed)) { - return true; - } - if (/^[@#]/.test(trimmed)) { - return true; - } - return /^[CUWGD][A-Z0-9]{8,}$/i.test(trimmed); -} +export { + looksLikeSlackTargetId, + normalizeSlackMessagingTarget, + parseSlackTarget, + resolveSlackChannelId, +} from "./target-parsing.js"; +export type { SlackTarget, SlackTargetKind, SlackTargetParseOptions } from "./target-parsing.js"; diff --git a/package.json b/package.json index c5610470a6c..3f1f6119cf8 100644 --- a/package.json +++ b/package.json @@ -447,6 +447,14 @@ "types": "./dist/plugin-sdk/channel-actions.d.ts", "default": "./dist/plugin-sdk/channel-actions.js" }, + "./plugin-sdk/channel-plugin-common": { + "types": "./dist/plugin-sdk/channel-plugin-common.d.ts", + "default": "./dist/plugin-sdk/channel-plugin-common.js" + }, + "./plugin-sdk/channel-core": { + "types": "./dist/plugin-sdk/channel-core.d.ts", + "default": "./dist/plugin-sdk/channel-core.js" + }, "./plugin-sdk/channel-contract": { "types": "./dist/plugin-sdk/channel-contract.d.ts", "default": "./dist/plugin-sdk/channel-contract.js" @@ -479,6 +487,10 @@ "types": "./dist/plugin-sdk/channel-targets.d.ts", "default": "./dist/plugin-sdk/channel-targets.js" }, + "./plugin-sdk/messaging-targets": { + "types": "./dist/plugin-sdk/messaging-targets.d.ts", + "default": "./dist/plugin-sdk/messaging-targets.js" + }, "./plugin-sdk/feishu": { "types": "./dist/plugin-sdk/feishu.d.ts", "default": "./dist/plugin-sdk/feishu.js" @@ -883,6 +895,10 @@ "types": "./dist/plugin-sdk/synthetic.d.ts", "default": "./dist/plugin-sdk/synthetic.js" }, + "./plugin-sdk/target-resolver-runtime": { + "types": "./dist/plugin-sdk/target-resolver-runtime.d.ts", + "default": "./dist/plugin-sdk/target-resolver-runtime.js" + }, "./plugin-sdk/thread-ownership": { "types": "./dist/plugin-sdk/thread-ownership.d.ts", "default": "./dist/plugin-sdk/thread-ownership.js" diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index 14da11c03cb..04ecaac7ea3 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -44,6 +44,7 @@ function collectTopLevelPublicSurfaceEntries(pluginDir) { const normalizedName = dirent.name.toLowerCase(); if ( + /^config-api\.(?:[cm]?[jt]s)$/u.test(normalizedName) || normalizedName.includes(".test.") || normalizedName.includes(".spec.") || normalizedName.includes(".fixture.") || diff --git a/scripts/lib/bundled-plugin-build-entries.mjs b/scripts/lib/bundled-plugin-build-entries.mjs index dccdec932e4..413106a764f 100644 --- a/scripts/lib/bundled-plugin-build-entries.mjs +++ b/scripts/lib/bundled-plugin-build-entries.mjs @@ -70,6 +70,7 @@ function collectTopLevelPublicSurfaceEntries(pluginDir) { const normalizedName = dirent.name.toLowerCase(); if ( normalizedName.endsWith(".d.ts") || + /^config-api\.(?:[cm]?[jt]s)$/u.test(normalizedName) || normalizedName.includes(".test.") || normalizedName.includes(".spec.") || normalizedName.includes(".fixture.") || diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 269bca270de..9bb36d7035c 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -101,6 +101,8 @@ "channel-config-primitives", "channel-config-schema", "channel-actions", + "channel-plugin-common", + "channel-core", "channel-contract", "channel-feedback", "channel-inbound", @@ -130,6 +132,7 @@ "realtime-transcription", "realtime-voice", "media-understanding", + "messaging-targets", "request-url", "runtime-store", "json-store", @@ -210,6 +213,7 @@ "string-normalization-runtime", "state-paths", "synthetic", + "target-resolver-runtime", "thread-ownership", "tlon", "tool-send", diff --git a/src/channels/chat-meta-shared.ts b/src/channels/chat-meta-shared.ts new file mode 100644 index 00000000000..d34befacedd --- /dev/null +++ b/src/channels/chat-meta-shared.ts @@ -0,0 +1,93 @@ +import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js"; +import type { PluginPackageChannel } from "../plugins/manifest.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 } : {}), + }; +} + +export 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; +} diff --git a/src/config/telegram-command-config.ts b/src/config/telegram-command-config.ts deleted file mode 100644 index 9a3ec2ee276..00000000000 --- a/src/config/telegram-command-config.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { getBundledChannelContractSurfaceModule } from "../channels/plugins/contract-surfaces.js"; - -export type TelegramCustomCommandInput = { - command?: string | null; - description?: string | null; -}; - -export type TelegramCustomCommandIssue = { - index: number; - field: "command" | "description"; - message: string; -}; - -type TelegramCommandConfigContract = { - TELEGRAM_COMMAND_NAME_PATTERN: RegExp; - normalizeTelegramCommandName: (value: string) => string; - normalizeTelegramCommandDescription: (value: string) => string; - resolveTelegramCustomCommands: (params: { - commands?: TelegramCustomCommandInput[] | null; - reservedCommands?: Set; - checkReserved?: boolean; - checkDuplicates?: boolean; - }) => { - commands: Array<{ command: string; description: string }>; - issues: TelegramCustomCommandIssue[]; - }; -}; - -function loadTelegramCommandConfigContract(): TelegramCommandConfigContract { - const contract = getBundledChannelContractSurfaceModule({ - pluginId: "telegram", - preferredBasename: "contract-surfaces.ts", - }); - if (!contract) { - throw new Error("telegram command config contract surface is unavailable"); - } - return contract; -} - -export const TELEGRAM_COMMAND_NAME_PATTERN = - loadTelegramCommandConfigContract().TELEGRAM_COMMAND_NAME_PATTERN; - -export function normalizeTelegramCommandName(value: string): string { - return loadTelegramCommandConfigContract().normalizeTelegramCommandName(value); -} - -export function normalizeTelegramCommandDescription(value: string): string { - return loadTelegramCommandConfigContract().normalizeTelegramCommandDescription(value); -} - -export function resolveTelegramCustomCommands(params: { - commands?: TelegramCustomCommandInput[] | null; - reservedCommands?: Set; - checkReserved?: boolean; - checkDuplicates?: boolean; -}): { - commands: Array<{ command: string; description: string }>; - issues: TelegramCustomCommandIssue[]; -} { - return loadTelegramCommandConfigContract().resolveTelegramCustomCommands(params); -} diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index f2c3b7e5065..2c6111a92e6 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -5,7 +5,7 @@ import { normalizeTelegramCommandDescription, normalizeTelegramCommandName, resolveTelegramCustomCommands, -} from "./telegram-command-config.js"; +} from "../plugin-sdk/telegram-command-config.js"; import { ToolPolicySchema } from "./zod-schema.agent-runtime.js"; import { ChannelHealthMonitorSchema, diff --git a/src/infra/outbound/current-conversation-bindings.ts b/src/infra/outbound/current-conversation-bindings.ts index 3708cba2d8f..03418c5075f 100644 --- a/src/infra/outbound/current-conversation-bindings.ts +++ b/src/infra/outbound/current-conversation-bindings.ts @@ -5,7 +5,7 @@ import { normalizeAnyChannelId } from "../../channels/registry.js"; import { resolveStateDir } from "../../config/paths.js"; import { loadJsonFile } from "../../infra/json-file.js"; import { writeJsonFileAtomically } from "../../plugin-sdk/json-store.js"; -import { getActivePluginChannelRegistry } from "../../plugins/runtime.js"; +import { getActivePluginChannelRegistryFromState } from "../../plugins/runtime-state.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import type { ConversationRef, @@ -127,9 +127,10 @@ function resolveChannelSupportsCurrentConversationBinding(channel: string): bool const matchesPluginId = (plugin: { id: string; meta?: { aliases?: readonly string[] } }) => plugin.id === normalized || (plugin.meta?.aliases ?? []).some((alias) => alias.trim().toLowerCase() === normalized); - // Keep this resolver on the active runtime registry only. Importing bundled - // channel loaders here creates a module cycle through plugin-sdk surfaces. - const plugin = getActivePluginChannelRegistry()?.channels.find((entry) => + // Read the already-installed runtime channel registry from shared state only. + // Importing plugins/runtime here creates a module cycle through plugin-sdk + // surfaces during bundled channel discovery. + const plugin = getActivePluginChannelRegistryFromState()?.channels.find((entry) => matchesPluginId(entry.plugin), )?.plugin; if (plugin?.conversationBindings?.supportsCurrentConversationBinding === true) { diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index 3b57057d88d..3b4ea92c2dd 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -1,6 +1,6 @@ -import { getBundledChannelContractSurfaceModule } from "../channels/plugins/contract-surfaces.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; +export { buildCommandsPaginationKeyboard } from "./telegram-command-ui.js"; export { createPreCryptoDirectDmAuthorizer, resolveInboundDirectDmAccessWithRuntime, @@ -86,37 +86,6 @@ export { buildHelpMessage, } from "../auto-reply/status.js"; -type TelegramCommandUiContract = { - buildCommandsPaginationKeyboard: ( - currentPage: number, - totalPages: number, - agentId?: string, - ) => Array>; -}; - -function loadTelegramCommandUiContract(): TelegramCommandUiContract { - const contract = getBundledChannelContractSurfaceModule({ - pluginId: "telegram", - preferredBasename: "contract-api.ts", - }); - if (!contract) { - throw new Error("telegram command ui contract surface is unavailable"); - } - return contract; -} - -export function buildCommandsPaginationKeyboard( - currentPage: number, - totalPages: number, - agentId?: string, -): Array> { - return loadTelegramCommandUiContract().buildCommandsPaginationKeyboard( - currentPage, - totalPages, - agentId, - ); -} - export type ResolveSenderCommandAuthorizationParams = { cfg: OpenClawConfig; rawBody: string; diff --git a/src/plugin-sdk/messaging-targets.ts b/src/plugin-sdk/messaging-targets.ts new file mode 100644 index 00000000000..c373f42d6c7 --- /dev/null +++ b/src/plugin-sdk/messaging-targets.ts @@ -0,0 +1,14 @@ +export { + buildMessagingTarget, + ensureTargetId, + normalizeTargetId, + parseAtUserTarget, + parseMentionPrefixOrAtUserTarget, + parseTargetMention, + parseTargetPrefix, + parseTargetPrefixes, + requireTargetKind, + type MessagingTarget, + type MessagingTargetKind, + type MessagingTargetParseOptions, +} from "../channels/targets.js"; diff --git a/src/plugin-sdk/string-normalization-runtime.ts b/src/plugin-sdk/string-normalization-runtime.ts index 97da1d7a98a..4871e2f69ef 100644 --- a/src/plugin-sdk/string-normalization-runtime.ts +++ b/src/plugin-sdk/string-normalization-runtime.ts @@ -1,4 +1,5 @@ export { + normalizeAtHashSlug, normalizeHyphenSlug, normalizeStringEntries, normalizeStringEntriesLower, diff --git a/src/plugin-sdk/target-resolver-runtime.ts b/src/plugin-sdk/target-resolver-runtime.ts new file mode 100644 index 00000000000..06b83916565 --- /dev/null +++ b/src/plugin-sdk/target-resolver-runtime.ts @@ -0,0 +1,4 @@ +export { + buildUnresolvedTargetResults, + resolveTargetsWithOptionalToken, +} from "../channels/plugins/target-resolvers.js"; diff --git a/src/plugin-sdk/telegram-command-config.ts b/src/plugin-sdk/telegram-command-config.ts index bad29b2ced2..9a3ec2ee276 100644 --- a/src/plugin-sdk/telegram-command-config.ts +++ b/src/plugin-sdk/telegram-command-config.ts @@ -1,8 +1,61 @@ -export { - TELEGRAM_COMMAND_NAME_PATTERN, - normalizeTelegramCommandDescription, - normalizeTelegramCommandName, - resolveTelegramCustomCommands, - type TelegramCustomCommandInput, - type TelegramCustomCommandIssue, -} from "../config/telegram-command-config.js"; +import { getBundledChannelContractSurfaceModule } from "../channels/plugins/contract-surfaces.js"; + +export type TelegramCustomCommandInput = { + command?: string | null; + description?: string | null; +}; + +export type TelegramCustomCommandIssue = { + index: number; + field: "command" | "description"; + message: string; +}; + +type TelegramCommandConfigContract = { + TELEGRAM_COMMAND_NAME_PATTERN: RegExp; + normalizeTelegramCommandName: (value: string) => string; + normalizeTelegramCommandDescription: (value: string) => string; + resolveTelegramCustomCommands: (params: { + commands?: TelegramCustomCommandInput[] | null; + reservedCommands?: Set; + checkReserved?: boolean; + checkDuplicates?: boolean; + }) => { + commands: Array<{ command: string; description: string }>; + issues: TelegramCustomCommandIssue[]; + }; +}; + +function loadTelegramCommandConfigContract(): TelegramCommandConfigContract { + const contract = getBundledChannelContractSurfaceModule({ + pluginId: "telegram", + preferredBasename: "contract-surfaces.ts", + }); + if (!contract) { + throw new Error("telegram command config contract surface is unavailable"); + } + return contract; +} + +export const TELEGRAM_COMMAND_NAME_PATTERN = + loadTelegramCommandConfigContract().TELEGRAM_COMMAND_NAME_PATTERN; + +export function normalizeTelegramCommandName(value: string): string { + return loadTelegramCommandConfigContract().normalizeTelegramCommandName(value); +} + +export function normalizeTelegramCommandDescription(value: string): string { + return loadTelegramCommandConfigContract().normalizeTelegramCommandDescription(value); +} + +export function resolveTelegramCustomCommands(params: { + commands?: TelegramCustomCommandInput[] | null; + reservedCommands?: Set; + checkReserved?: boolean; + checkDuplicates?: boolean; +}): { + commands: Array<{ command: string; description: string }>; + issues: TelegramCustomCommandIssue[]; +} { + return loadTelegramCommandConfigContract().resolveTelegramCustomCommands(params); +} diff --git a/src/plugin-sdk/telegram-command-ui.ts b/src/plugin-sdk/telegram-command-ui.ts new file mode 100644 index 00000000000..b9a303deb2e --- /dev/null +++ b/src/plugin-sdk/telegram-command-ui.ts @@ -0,0 +1,32 @@ +import { getBundledChannelContractSurfaceModule } from "../channels/plugins/contract-surfaces.js"; + +type TelegramCommandUiContract = { + buildCommandsPaginationKeyboard: ( + currentPage: number, + totalPages: number, + agentId?: string, + ) => Array>; +}; + +function loadTelegramCommandUiContract(): TelegramCommandUiContract { + const contract = getBundledChannelContractSurfaceModule({ + pluginId: "telegram", + preferredBasename: "contract-api.ts", + }); + if (!contract) { + throw new Error("telegram command ui contract surface is unavailable"); + } + return contract; +} + +export function buildCommandsPaginationKeyboard( + currentPage: number, + totalPages: number, + agentId?: string, +): Array> { + return loadTelegramCommandUiContract().buildCommandsPaginationKeyboard( + currentPage, + totalPages, + agentId, + ); +} diff --git a/src/plugin-sdk/thread-bindings-runtime.ts b/src/plugin-sdk/thread-bindings-runtime.ts index 8ca19f3608d..cacbb56ba03 100644 --- a/src/plugin-sdk/thread-bindings-runtime.ts +++ b/src/plugin-sdk/thread-bindings-runtime.ts @@ -2,8 +2,13 @@ // expiry and session-binding record types without loading the full // conversation-runtime surface. +export { resolveThreadBindingConversationIdFromBindingId } from "../channels/thread-binding-id.js"; export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js"; -export { resolveThreadBindingLifecycle } from "../channels/thread-bindings-policy.js"; +export { + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingLifecycle, + resolveThreadBindingMaxAgeMsForChannel, +} from "../channels/thread-bindings-policy.js"; export type { BindingTargetKind, SessionBindingAdapter, diff --git a/src/plugins/bundled-plugin-metadata.ts b/src/plugins/bundled-plugin-metadata.ts index 5ef7c8c43c2..4a1af2d69dd 100644 --- a/src/plugins/bundled-plugin-metadata.ts +++ b/src/plugins/bundled-plugin-metadata.ts @@ -150,6 +150,9 @@ function isTopLevelPublicSurfaceSource(name: string): boolean { if (name.endsWith(".d.ts")) { return false; } + if (/^config-api(\.[cm]?[jt]s)$/u.test(name)) { + return false; + } return !/(\.test|\.spec)(\.[cm]?[jt]s)$/u.test(name); } diff --git a/src/plugins/runtime-state.ts b/src/plugins/runtime-state.ts new file mode 100644 index 00000000000..f063a72bb07 --- /dev/null +++ b/src/plugins/runtime-state.ts @@ -0,0 +1,32 @@ +import type { PluginRegistry } from "./registry.js"; + +export const PLUGIN_REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); + +export type RegistrySurfaceState = { + registry: PluginRegistry | null; + pinned: boolean; + version: number; +}; + +export type RegistryState = { + activeRegistry: PluginRegistry | null; + activeVersion: number; + httpRoute: RegistrySurfaceState; + channel: RegistrySurfaceState; + key: string | null; + runtimeSubagentMode: "default" | "explicit" | "gateway-bindable"; + importedPluginIds: Set; +}; + +type GlobalRegistryState = typeof globalThis & { + [PLUGIN_REGISTRY_STATE]?: RegistryState; +}; + +export function getPluginRegistryState(): RegistryState | undefined { + return (globalThis as GlobalRegistryState)[PLUGIN_REGISTRY_STATE]; +} + +export function getActivePluginChannelRegistryFromState(): PluginRegistry | null { + const state = getPluginRegistryState(); + return state?.channel.registry ?? state?.activeRegistry ?? null; +} diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 175a8df790f..d3d27bfcb31 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -1,30 +1,17 @@ import { createEmptyPluginRegistry } from "./registry-empty.js"; import type { PluginRegistry } from "./registry.js"; - -const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); - -type RegistrySurfaceState = { - registry: PluginRegistry | null; - pinned: boolean; - version: number; -}; - -type RegistryState = { - activeRegistry: PluginRegistry | null; - activeVersion: number; - httpRoute: RegistrySurfaceState; - channel: RegistrySurfaceState; - key: string | null; - runtimeSubagentMode: "default" | "explicit" | "gateway-bindable"; - importedPluginIds: Set; -}; +import { + PLUGIN_REGISTRY_STATE, + type RegistryState, + type RegistrySurfaceState, +} from "./runtime-state.js"; const state: RegistryState = (() => { const globalState = globalThis as typeof globalThis & { - [REGISTRY_STATE]?: RegistryState; + [PLUGIN_REGISTRY_STATE]?: RegistryState; }; - if (!globalState[REGISTRY_STATE]) { - globalState[REGISTRY_STATE] = { + if (!globalState[PLUGIN_REGISTRY_STATE]) { + globalState[PLUGIN_REGISTRY_STATE] = { activeRegistry: null, activeVersion: 0, httpRoute: { @@ -42,7 +29,7 @@ const state: RegistryState = (() => { importedPluginIds: new Set(), }; } - return globalState[REGISTRY_STATE]; + return globalState[PLUGIN_REGISTRY_STATE]; })(); export function recordImportedPluginId(pluginId: string): void { diff --git a/src/secrets/channel-contract-surface-guardrails.test.ts b/src/secrets/channel-contract-surface-guardrails.test.ts index 0ff5069271b..9be86cc4808 100644 --- a/src/secrets/channel-contract-surface-guardrails.test.ts +++ b/src/secrets/channel-contract-surface-guardrails.test.ts @@ -47,6 +47,10 @@ const CORE_SECRET_SURFACE_GUARDS = [ /\bWhatsAppConfigSchema\b/, ], }, + { + path: "src/plugin-sdk/command-auth.ts", + forbiddenPatterns: [/\bpluginId:\s*"telegram"/], + }, ] as const; describe("channel secret contract surface guardrails", () => { diff --git a/test/helpers/channels/session-binding-registry-backed-contract.ts b/test/helpers/channels/session-binding-registry-backed-contract.ts index 7509309f4da..4ac426a9f28 100644 --- a/test/helpers/channels/session-binding-registry-backed-contract.ts +++ b/test/helpers/channels/session-binding-registry-backed-contract.ts @@ -41,7 +41,7 @@ function getBluebubblesPlugin(): ChannelPlugin { if (!bluebubblesPluginCache) { ({ bluebubblesPlugin: bluebubblesPluginCache } = loadBundledPluginPublicSurfaceSync<{ bluebubblesPlugin: ChannelPlugin; - }>({ pluginId: "bluebubbles", artifactBasename: "api.js" })); + }>({ pluginId: "bluebubbles", artifactBasename: "index.js" })); } return bluebubblesPluginCache; }