refactor: register channel bootstrap capabilities

This commit is contained in:
Peter Steinberger
2026-04-05 09:13:30 +01:00
parent a5b6b71468
commit 41e39eb46f
58 changed files with 444 additions and 949 deletions

View File

@@ -1,5 +0,0 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -13,11 +13,11 @@ import {
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/channel-status";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
@@ -35,17 +35,18 @@ import {
} from "./channel-shared.js";
import type { BlueBubblesProbe } from "./channel.runtime.js";
import { createBlueBubblesConversationBindingManager } from "./conversation-bindings.js";
import { bluebubblesDoctor } from "./doctor.js";
import {
matchBlueBubblesAcpConversation,
normalizeBlueBubblesAcpConversationId,
resolveBlueBubblesConversationIdFromTarget,
} from "./conversation-id.js";
import { bluebubblesDoctor } from "./doctor.js";
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { resolveBlueBubblesOutboundSessionRoute } from "./session-route.js";
import { blueBubblesSetupAdapter } from "./setup-core.js";
import { blueBubblesSetupWizard } from "./setup-surface.js";
@@ -112,6 +113,10 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
}),
},
actions: bluebubblesMessageActions,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeBlueBubblesAcpConversationId(conversationId),

View File

@@ -1,17 +0,0 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-config-contract.js";
export {
unsupportedSecretRefSurfacePatterns,
collectUnsupportedSecretRefConfigCandidates,
} from "./src/security-contract.js";
export { deriveLegacySessionChatType } from "./src/session-contract.js";
export function hasConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return (
typeof params.env?.DISCORD_BOT_TOKEN === "string" &&
params.env.DISCORD_BOT_TOKEN.trim().length > 0
);
}

View File

@@ -14,6 +14,15 @@ import {
import { getChatChannelMeta, type ChannelPlugin } from "./channel-api.js";
import { DiscordChannelConfigSchema } from "./config-schema.js";
import { DISCORD_LEGACY_CONFIG_RULES } from "./doctor-shared.js";
import {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./secret-config-contract.js";
import {
collectUnsupportedSecretRefConfigCandidates,
unsupportedSecretRefSurfacePatterns,
} from "./security-contract.js";
import { deriveLegacySessionChatType } from "./session-contract.js";
export const DISCORD_CHANNEL = "discord" as const;
@@ -87,6 +96,8 @@ export function createDiscordPluginBase(params: {
| "configSchema"
| "config"
| "setup"
| "messaging"
| "secrets"
> {
return {
id: DISCORD_CHANNEL,
@@ -114,6 +125,8 @@ export function createDiscordPluginBase(params: {
configSchema: DiscordChannelConfigSchema,
config: {
...discordConfigAdapter,
hasConfiguredState: ({ env }) =>
typeof env?.DISCORD_BOT_TOKEN === "string" && env.DISCORD_BOT_TOKEN.trim().length > 0,
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) =>
describeAccountSnapshot({
@@ -124,6 +137,15 @@ export function createDiscordPluginBase(params: {
},
}),
},
messaging: {
deriveLegacySessionChatType,
},
secrets: {
secretTargetRegistryEntries,
unsupportedSecretRefSurfacePatterns,
collectUnsupportedSecretRefConfigCandidates,
collectRuntimeConfigAssignments,
},
setup: params.setup,
} as Pick<
ChannelPlugin<ResolvedDiscordAccount>,
@@ -138,5 +160,7 @@ export function createDiscordPluginBase(params: {
| "configSchema"
| "config"
| "setup"
| "messaging"
| "secrets"
>;
}

View File

@@ -1,5 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { messageActionTargetAliases } from "./src/message-action-contract.js";

View File

@@ -58,8 +58,10 @@ import {
parseFeishuTargetId,
} from "./conversation-id.js";
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
import { messageActionTargetAliases } from "./message-action-contract.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { getFeishuRuntime } from "./runtime.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { collectFeishuSecurityAuditFindings } from "./security-audit.js";
import {
resolveFeishuParentConversationCandidates,
@@ -641,7 +643,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
}),
},
auth: feishuApprovalAuth,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
actions: {
messageActionTargetAliases,
describeMessageTool: describeFeishuMessageTool,
handleAction: async (ctx) => {
const account = resolveFeishuAccount({

View File

@@ -1,4 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -53,6 +53,7 @@ import {
import { collectGoogleChatMutableAllowlistWarnings } from "./doctor.js";
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
import { getGoogleChatRuntime } from "./runtime.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { googlechatSetupAdapter } from "./setup-core.js";
import { googlechatSetupWizard } from "./setup-surface.js";
@@ -163,6 +164,10 @@ export const googlechatPlugin = createChatChannelPlugin({
}),
},
auth: googleChatApprovalAuth,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
groups: {
resolveRequireMention: resolveGoogleChatGroupRequireMention,
},

View File

@@ -1,103 +0,0 @@
import path from "node:path";
import type { OpenClawConfig } from "./runtime-api.js";
import { resolveIMessageAccount } from "./src/accounts.js";
const DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = ["/Users/*/Library/Messages/Attachments"] as const;
const WILDCARD_SEGMENT = "*";
const WINDOWS_DRIVE_ABS_RE = /^[A-Za-z]:\//;
const WINDOWS_DRIVE_ROOT_RE = /^[A-Za-z]:$/;
function normalizePosixAbsolutePath(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed || trimmed.includes("\0")) {
return undefined;
}
const normalized = path.posix.normalize(trimmed.replaceAll("\\", "/"));
const isAbsolute = normalized.startsWith("/") || WINDOWS_DRIVE_ABS_RE.test(normalized);
if (!isAbsolute || normalized === "/") {
return undefined;
}
const withoutTrailingSlash = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
if (WINDOWS_DRIVE_ROOT_RE.test(withoutTrailingSlash)) {
return undefined;
}
return withoutTrailingSlash;
}
function splitPathSegments(value: string): string[] {
return value.split("/").filter(Boolean);
}
function isValidInboundPathRootPattern(value: string): boolean {
const normalized = normalizePosixAbsolutePath(value);
if (!normalized) {
return false;
}
const segments = splitPathSegments(normalized);
if (segments.length === 0) {
return false;
}
return segments.every((segment) => segment === WILDCARD_SEGMENT || !segment.includes("*"));
}
function normalizeInboundPathRoots(roots?: readonly string[]): string[] {
const normalized: string[] = [];
const seen = new Set<string>();
for (const root of roots ?? []) {
if (typeof root !== "string") {
continue;
}
if (!isValidInboundPathRootPattern(root)) {
continue;
}
const candidate = normalizePosixAbsolutePath(root);
if (!candidate || seen.has(candidate)) {
continue;
}
seen.add(candidate);
normalized.push(candidate);
}
return normalized;
}
function mergeInboundPathRoots(...rootsLists: Array<readonly string[] | undefined>): string[] {
const merged: string[] = [];
const seen = new Set<string>();
for (const roots of rootsLists) {
const normalized = normalizeInboundPathRoots(roots);
for (const root of normalized) {
if (seen.has(root)) {
continue;
}
seen.add(root);
merged.push(root);
}
}
return merged;
}
export function resolveInboundAttachmentRoots(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveIMessageAccount(params);
return mergeInboundPathRoots(
account.config.attachmentRoots,
params.cfg.channels?.imessage?.attachmentRoots,
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
);
}
export function resolveRemoteInboundAttachmentRoots(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveIMessageAccount(params);
return mergeInboundPathRoots(
account.config.remoteAttachmentRoots,
params.cfg.channels?.imessage?.remoteAttachmentRoots,
account.config.attachmentRoots,
params.cfg.channels?.imessage?.attachmentRoots,
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
);
}

View File

@@ -14,6 +14,10 @@ import {
} from "./accounts.js";
import { getChatChannelMeta, type ChannelPlugin } from "./channel-api.js";
import { IMessageChannelConfigSchema } from "./config-schema.js";
import {
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./media-contract.js";
import { createIMessageSetupWizardProxy } from "./setup-core.js";
export const IMESSAGE_CHANNEL = "imessage" as const;
@@ -65,8 +69,9 @@ export function createIMessagePluginBase(params: {
| "config"
| "security"
| "setup"
| "messaging"
> {
return createChannelPluginBase({
const base = createChannelPluginBase({
id: IMESSAGE_CHANNEL,
meta: {
...getChatChannelMeta(IMESSAGE_CHANNEL),
@@ -91,7 +96,16 @@ export function createIMessagePluginBase(params: {
},
security: imessageSecurityAdapter,
setup: params.setup,
}) as Pick<
});
return {
...base,
messaging: {
resolveInboundAttachmentRoots: (params) =>
resolveIMessageAttachmentRoots({ accountId: params.accountId, cfg: params.cfg }),
resolveRemoteInboundAttachmentRoots: (params) =>
resolveIMessageRemoteAttachmentRoots({ accountId: params.accountId, cfg: params.cfg }),
},
} as Pick<
ChannelPlugin<ResolvedIMessageAccount>,
| "id"
| "meta"
@@ -102,5 +116,6 @@ export function createIMessagePluginBase(params: {
| "config"
| "security"
| "setup"
| "messaging"
>;
}

View File

@@ -1,13 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export function hasConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return (
typeof params.env?.IRC_HOST === "string" &&
params.env.IRC_HOST.trim().length > 0 &&
typeof params.env?.IRC_NICK === "string" &&
params.env.IRC_NICK.trim().length > 0
);
}

View File

@@ -48,6 +48,7 @@ import {
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
import { probeIrc } from "./probe.js";
import { getIrcRuntime } from "./runtime.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { sendMessageIrc } from "./send.js";
import { ircSetupAdapter } from "./setup-core.js";
import { ircSetupWizard } from "./setup-surface.js";
@@ -174,6 +175,11 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
configSchema: IrcChannelConfigSchema,
config: {
...ircConfigAdapter,
hasConfiguredState: ({ env }) =>
typeof env?.IRC_HOST === "string" &&
env.IRC_HOST.trim().length > 0 &&
typeof env?.IRC_NICK === "string" &&
env.IRC_NICK.trim().length > 0,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
@@ -188,6 +194,10 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
},
}),
},
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
doctor: {
groupAllowFromFallbackToAllowFrom: false,
collectMutableAllowlistWarnings: collectIrcMutableAllowlistWarnings,

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,10 +0,0 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
singleAccountKeysToMove,
} from "./src/setup-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -62,7 +62,13 @@ import {
setMatrixThreadBindingMaxAgeBySessionKey,
} from "./matrix/thread-bindings-shared.js";
import { getMatrixRuntime } from "./runtime.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
import {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
singleAccountKeysToMove,
} from "./setup-contract.js";
import { matrixSetupAdapter } from "./setup-core.js";
import { matrixSetupWizard } from "./setup-surface.js";
import { runMatrixStartupMaintenance } from "./startup-maintenance.js";
@@ -452,7 +458,16 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
}),
},
actions: matrixMessageActions,
setup: matrixSetupAdapter,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
setup: {
...matrixSetupAdapter,
singleAccountKeysToMove,
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeMatrixAcpConversationId(conversationId),

View File

@@ -1,7 +0,0 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export const defaultMarkdownTableMode = "off";

View File

@@ -16,11 +16,11 @@ import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channe
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { mattermostApprovalAuth } from "./approval-auth.js";
import {
chunkTextForOutbound,
@@ -52,6 +52,7 @@ import { collectMattermostSlashCallbackPaths } from "./mattermost/slash-commands
import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
import { getMattermostRuntime } from "./runtime.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { resolveMattermostOutboundSessionRoute } from "./session-route.js";
import { mattermostSetupAdapter } from "./setup-core.js";
import { mattermostSetupWizard } from "./setup-surface.js";
@@ -336,6 +337,10 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
resolveRequireMention: resolveMattermostGroupRequireMention,
},
actions: mattermostMessageActions,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
directory: createChannelDirectoryAdapter({
listGroups: async (params) => listMattermostDirectoryGroups(params),
listGroupsLive: async (params) => listMattermostDirectoryGroups(params),
@@ -343,6 +348,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
listPeersLive: async (params) => listMattermostDirectoryPeers(params),
}),
messaging: {
defaultMarkdownTableMode: "off",
normalizeTarget: normalizeMattermostMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveMattermostOutboundSessionRoute(params),
targetResolver: {

View File

@@ -1,4 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -45,6 +45,7 @@ import {
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { resolveMSTeamsOutboundSessionRoute } from "./session-route.js";
import { msteamsSetupAdapter } from "./setup-core.js";
import { msteamsSetupWizard } from "./setup-surface.js";
@@ -396,6 +397,10 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
collectMutableAllowlistWarnings: collectMSTeamsMutableAllowlistWarnings,
},
setup: msteamsSetupAdapter,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
messaging: {
normalizeTarget: normalizeMSTeamsMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveMSTeamsOutboundSessionRoute(params),

View File

@@ -1,5 +0,0 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -41,6 +41,7 @@ import {
} from "./normalize.js";
import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { sendMessageNextcloudTalk } from "./send.js";
import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js";
import { nextcloudTalkSetupAdapter } from "./setup-core.js";
@@ -173,6 +174,10 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
hint: "<roomToken>",
},
},
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
setup: nextcloudTalkSetupAdapter,
status: createComputedAccountStatusAdapter<ResolvedNextcloudTalkAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),

View File

@@ -1 +0,0 @@
export const defaultMarkdownTableMode = "bullets";

View File

@@ -71,8 +71,9 @@ export function createSignalPluginBase(params: {
| "config"
| "security"
| "setup"
| "messaging"
> {
return createChannelPluginBase({
const base = createChannelPluginBase({
id: SIGNAL_CHANNEL,
meta: {
...getChatChannelMeta(SIGNAL_CHANNEL),
@@ -102,7 +103,13 @@ export function createSignalPluginBase(params: {
},
security: signalSecurityAdapter,
setup: params.setup,
}) as Pick<
});
return {
...base,
messaging: {
defaultMarkdownTableMode: "bullets",
},
} as Pick<
ChannelPlugin<ResolvedSignalAccount>,
| "id"
| "meta"
@@ -114,5 +121,6 @@ export function createSignalPluginBase(params: {
| "config"
| "security"
| "setup"
| "messaging"
>;
}

View File

@@ -1,11 +0,0 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export function hasConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return ["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"].some(
(key) => typeof params.env?.[key] === "string" && params.env[key]?.trim().length > 0,
);
}

View File

@@ -18,6 +18,7 @@ import { getChatChannelMeta, type ChannelPlugin, type OpenClawConfig } from "./c
import { SlackChannelConfigSchema } from "./config-schema.js";
import { slackDoctor } from "./doctor.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
export const SLACK_CHANNEL = "slack" as const;
@@ -174,6 +175,7 @@ export function createSlackPluginBase(params: {
| "configSchema"
| "config"
| "setup"
| "secrets"
> {
return {
id: SLACK_CHANNEL,
@@ -225,6 +227,10 @@ export function createSlackPluginBase(params: {
configSchema: SlackChannelConfigSchema,
config: {
...slackConfigAdapter,
hasConfiguredState: ({ env }) =>
["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"].some(
(key) => typeof env?.[key] === "string" && env[key]?.trim().length > 0,
),
isConfigured: (account) => isSlackPluginAccountConfigured(account),
describeAccount: (account) =>
describeAccountSnapshot({
@@ -236,6 +242,10 @@ export function createSlackPluginBase(params: {
},
}),
},
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
setup: params.setup,
} as Pick<
ChannelPlugin<ResolvedSlackAccount>,
@@ -251,5 +261,6 @@ export function createSlackPluginBase(params: {
| "configSchema"
| "config"
| "setup"
| "secrets"
>;
}

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,19 +0,0 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export {
TELEGRAM_COMMAND_NAME_PATTERN,
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
resolveTelegramCustomCommands,
} from "./src/command-config.js";
export { singleAccountKeysToMove } from "./src/setup-contract.js";
export function hasConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return (
typeof params.env?.TELEGRAM_BOT_TOKEN === "string" &&
params.env.TELEGRAM_BOT_TOKEN.trim().length > 0
);
}

View File

@@ -27,6 +27,8 @@ import {
} from "./command-ui.js";
import { TelegramChannelConfigSchema } from "./config-schema.js";
import { telegramDoctor } from "./doctor.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { singleAccountKeysToMove } from "./setup-contract.js";
export const TELEGRAM_CHANNEL = "telegram" as const;
@@ -125,8 +127,9 @@ export function createTelegramPluginBase(params: {
| "configSchema"
| "config"
| "setup"
| "secrets"
> {
return createChannelPluginBase({
const base = createChannelPluginBase({
id: TELEGRAM_CHANNEL,
meta: {
...getChatChannelMeta(TELEGRAM_CHANNEL),
@@ -155,6 +158,8 @@ export function createTelegramPluginBase(params: {
configSchema: TelegramChannelConfigSchema,
config: {
...telegramConfigAdapter,
hasConfiguredState: ({ env }) =>
typeof env?.TELEGRAM_BOT_TOKEN === "string" && env.TELEGRAM_BOT_TOKEN.trim().length > 0,
isConfigured: (account, cfg) => {
// Use inspectTelegramAccount for a complete token resolution that includes
// channel-level fallback paths not available in resolveTelegramAccount.
@@ -218,8 +223,18 @@ export function createTelegramPluginBase(params: {
};
},
},
setup: params.setup,
}) as Pick<
setup: {
...params.setup,
singleAccountKeysToMove,
},
});
return {
...base,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
} as Pick<
ChannelPlugin<ResolvedTelegramAccount>,
| "id"
| "meta"
@@ -231,5 +246,6 @@ export function createTelegramPluginBase(params: {
| "configSchema"
| "config"
| "setup"
| "secrets"
>;
}

View File

@@ -1 +0,0 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View File

@@ -1,63 +0,0 @@
type UnsupportedSecretRefConfigCandidate = {
path: string;
value: unknown;
};
export { normalizeCompatibilityConfig } from "./src/doctor-contract.js";
import { hasAnyWhatsAppAuth } from "./src/accounts.js";
export { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export const unsupportedSecretRefSurfacePatterns = [
"channels.whatsapp.creds.json",
"channels.whatsapp.accounts.*.creds.json",
] as const;
export const defaultMarkdownTableMode = "bullets";
export { resolveLegacyGroupSessionKey } from "./src/group-session-contract.js";
export function hasPersistedAuthState(params: {
cfg: import("openclaw/plugin-sdk/config-runtime").OpenClawConfig;
}): boolean {
return hasAnyWhatsAppAuth(params.cfg);
}
export function collectUnsupportedSecretRefConfigCandidates(
raw: unknown,
): UnsupportedSecretRefConfigCandidate[] {
if (!isRecord(raw)) {
return [];
}
if (!isRecord(raw.channels) || !isRecord(raw.channels.whatsapp)) {
return [];
}
const candidates: UnsupportedSecretRefConfigCandidate[] = [];
const whatsapp = raw.channels.whatsapp;
const creds = isRecord(whatsapp.creds) ? whatsapp.creds : null;
if (creds) {
candidates.push({
path: "channels.whatsapp.creds.json",
value: creds.json,
});
}
const accounts = isRecord(whatsapp.accounts) ? whatsapp.accounts : null;
if (!accounts) {
return candidates;
}
for (const [accountId, account] of Object.entries(accounts)) {
if (!isRecord(account) || !isRecord(account.creds)) {
continue;
}
candidates.push({
path: `channels.whatsapp.accounts.${accountId}.creds.json`,
value: account.creds.json,
});
}
return candidates;
}

View File

@@ -27,9 +27,53 @@ import {
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupToolPolicy,
} from "./group-policy.js";
import { resolveLegacyGroupSessionKey } from "./group-session-contract.js";
import { applyWhatsAppSecurityConfigFixes } from "./security-fix.js";
import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./session-contract.js";
export const WHATSAPP_CHANNEL = "whatsapp" as const;
const WHATSAPP_UNSUPPORTED_SECRET_REF_SURFACE_PATTERNS = [
"channels.whatsapp.creds.json",
"channels.whatsapp.accounts.*.creds.json",
] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function collectUnsupportedSecretRefConfigCandidates(raw: unknown): Array<{
path: string;
value: unknown;
}> {
if (!isRecord(raw) || !isRecord(raw.channels) || !isRecord(raw.channels.whatsapp)) {
return [];
}
const candidates: Array<{ path: string; value: unknown }> = [];
const whatsapp = raw.channels.whatsapp;
const creds = isRecord(whatsapp.creds) ? whatsapp.creds : null;
if (creds) {
candidates.push({
path: "channels.whatsapp.creds.json",
value: creds.json,
});
}
const accounts = isRecord(whatsapp.accounts) ? whatsapp.accounts : null;
if (!accounts) {
return candidates;
}
for (const [accountId, account] of Object.entries(accounts)) {
if (!isRecord(account) || !isRecord(account.creds)) {
continue;
}
candidates.push({
path: `channels.whatsapp.accounts.${accountId}.creds.json`,
value: account.creds.json,
});
}
return candidates;
}
export async function loadWhatsAppChannelRuntime() {
return await import("./channel.runtime.js");
@@ -172,6 +216,17 @@ export function createWhatsAppPluginBase(params: {
gatewayMethods: base.gatewayMethods!,
configSchema: base.configSchema!,
config: base.config!,
messaging: {
defaultMarkdownTableMode: "bullets",
resolveLegacyGroupSessionKey,
isLegacyGroupSessionKey,
canonicalizeLegacySessionKey: (params) =>
canonicalizeLegacySessionKey({ key: params.key, agentId: params.agentId }),
},
secrets: {
unsupportedSecretRefSurfacePatterns: WHATSAPP_UNSUPPORTED_SECRET_REF_SURFACE_PATTERNS,
collectUnsupportedSecretRefConfigCandidates,
},
security: base.security!,
groups: base.groups!,
} satisfies Pick<
@@ -184,6 +239,8 @@ export function createWhatsAppPluginBase(params: {
| "gatewayMethods"
| "configSchema"
| "config"
| "messaging"
| "secrets"
| "security"
| "doctor"
| "setup"

View File

@@ -1,4 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -43,6 +43,7 @@ import { zaloMessageActions } from "./actions.js";
import { zaloApprovalAuth } from "./approval-auth.js";
import { ZaloConfigSchema } from "./config-schema.js";
import type { ZaloProbeResult } from "./probe.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { resolveZaloOutboundSessionRoute } from "./session-route.js";
import { createZaloSetupWizardProxy, zaloSetupAdapter } from "./setup-core.js";
import { collectZaloStatusIssues } from "./status-issues.js";
@@ -198,6 +199,10 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount, ZaloProbeResult> =
}),
},
auth: zaloApprovalAuth,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
groups: {
resolveRequireMention: () => true,
},

View File

@@ -1 +0,0 @@
export {};

View File

@@ -2,8 +2,8 @@ import fs from "node:fs";
import os from "node:os";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { listBootstrapChannelPlugins } from "./plugins/bootstrap-registry.js";
import { listBundledChannelPluginIds } from "./plugins/bundled-ids.js";
import { listBundledChannelPlugins } from "./plugins/bundled.js";
const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
@@ -68,7 +68,7 @@ export function listPotentialConfiguredChannelIds(
}
if (options.includePersistedAuthState !== false && hasPersistedChannelState(env)) {
for (const plugin of listBundledChannelPlugins()) {
for (const plugin of listBootstrapChannelPlugins()) {
if (plugin.config?.hasPersistedAuthState?.({ cfg, env })) {
configuredChannelIds.add(plugin.id);
}
@@ -95,7 +95,7 @@ function hasEnvConfiguredChannel(
if (options.includePersistedAuthState === false || !hasPersistedChannelState(env)) {
return false;
}
return listBundledChannelPlugins().some((plugin) =>
return listBootstrapChannelPlugins().some((plugin) =>
Boolean(plugin.config?.hasPersistedAuthState?.({ cfg, env })),
);
}

View File

@@ -0,0 +1,81 @@
import { listBundledChannelPlugins, listBundledChannelSetupPlugins } from "./bundled.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
type CachedBootstrapPlugins = {
sorted: ChannelPlugin[];
byId: Map<string, ChannelPlugin>;
};
let cachedBootstrapPlugins: CachedBootstrapPlugins | null = null;
function mergePluginSection<T>(
runtimeValue: T | undefined,
setupValue: T | undefined,
): T | undefined {
if (
runtimeValue &&
setupValue &&
typeof runtimeValue === "object" &&
typeof setupValue === "object"
) {
return {
...(runtimeValue as Record<string, unknown>),
...(setupValue as Record<string, unknown>),
} as T;
}
return setupValue ?? runtimeValue;
}
function mergeBootstrapPlugin(
runtimePlugin: ChannelPlugin,
setupPlugin: ChannelPlugin,
): ChannelPlugin {
return {
...runtimePlugin,
...setupPlugin,
meta: mergePluginSection(runtimePlugin.meta, setupPlugin.meta),
capabilities: mergePluginSection(runtimePlugin.capabilities, setupPlugin.capabilities),
commands: mergePluginSection(runtimePlugin.commands, setupPlugin.commands),
doctor: mergePluginSection(runtimePlugin.doctor, setupPlugin.doctor),
reload: mergePluginSection(runtimePlugin.reload, setupPlugin.reload),
config: mergePluginSection(runtimePlugin.config, setupPlugin.config),
setup: mergePluginSection(runtimePlugin.setup, setupPlugin.setup),
messaging: mergePluginSection(runtimePlugin.messaging, setupPlugin.messaging),
actions: mergePluginSection(runtimePlugin.actions, setupPlugin.actions),
secrets: mergePluginSection(runtimePlugin.secrets, setupPlugin.secrets),
} as ChannelPlugin;
}
function buildBootstrapPlugins(): CachedBootstrapPlugins {
const byId = new Map<string, ChannelPlugin>();
for (const plugin of listBundledChannelPlugins()) {
byId.set(plugin.id, plugin);
}
for (const plugin of listBundledChannelSetupPlugins()) {
const runtimePlugin = byId.get(plugin.id);
byId.set(plugin.id, runtimePlugin ? mergeBootstrapPlugin(runtimePlugin, plugin) : plugin);
}
const sorted = [...byId.values()].toSorted((left, right) => left.id.localeCompare(right.id));
return { sorted, byId };
}
function getBootstrapPlugins(): CachedBootstrapPlugins {
cachedBootstrapPlugins ??= buildBootstrapPlugins();
return cachedBootstrapPlugins;
}
export function listBootstrapChannelPlugins(): readonly ChannelPlugin[] {
return getBootstrapPlugins().sorted;
}
export function getBootstrapChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
const resolvedId = String(id).trim();
if (!resolvedId) {
return undefined;
}
return getBootstrapPlugins().byId.get(resolvedId);
}
export function clearBootstrapChannelPluginCache(): void {
cachedBootstrapPlugins = null;
}

View File

@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import { getBundledChannelContractSurfaceModule } from "./contract-surfaces.js";
describe("bundled channel contract surfaces", () => {
it("resolves Telegram contract surfaces from a source checkout", () => {
const surface = getBundledChannelContractSurfaceModule<{
normalizeTelegramCommandName?: (value: string) => string;
}>({
pluginId: "telegram",
preferredBasename: "contract-surfaces.ts",
});
expect(surface).not.toBeNull();
expect(surface?.normalizeTelegramCommandName?.("/Hello-World")).toBe("hello_world");
});
it.each(["matrix", "mattermost", "bluebubbles", "nextcloud-talk", "tlon"])(
"exposes legacy migration hooks for %s from a source checkout",
(pluginId) => {
const surface = getBundledChannelContractSurfaceModule<{
normalizeCompatibilityConfig?: (params: { cfg: Record<string, unknown> }) => {
config: Record<string, unknown>;
changes: string[];
};
legacyConfigRules?: unknown[];
}>({
pluginId,
preferredBasename: "contract-surfaces.ts",
});
expect(surface).not.toBeNull();
expect(surface?.normalizeCompatibilityConfig).toBeTypeOf("function");
expect(Array.isArray(surface?.legacyConfigRules)).toBe(true);
},
);
});

View File

@@ -1,252 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { discoverOpenClawPlugins } from "../../plugins/discovery.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
resolveLoaderPackageRoot,
shouldPreferNativeJiti,
} from "../../plugins/sdk-alias.js";
const CONTRACT_SURFACE_BASENAMES = [
"contract-surfaces.ts",
"contract-surfaces.js",
"contract-api.ts",
"contract-api.js",
] as const;
const PUBLIC_SURFACE_SOURCE_EXTENSIONS = [".ts", ".mts", ".js", ".mjs", ".cts", ".cjs"] as const;
const OPENCLAW_PACKAGE_ROOT =
resolveLoaderPackageRoot({
modulePath: fileURLToPath(import.meta.url),
moduleUrl: import.meta.url,
}) ?? fileURLToPath(new URL("../../..", import.meta.url));
const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url);
const RUNNING_FROM_BUILT_ARTIFACT =
CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`) ||
CURRENT_MODULE_PATH.includes(`${path.sep}dist-runtime${path.sep}`);
type ContractSurfaceBasename = (typeof CONTRACT_SURFACE_BASENAMES)[number];
let cachedSurfaces: unknown[] | null = null;
let cachedSurfaceEntries: Array<{
pluginId: string;
surface: unknown;
}> | null = null;
const cachedPreferredSurfaceModules = new Map<string, unknown>();
function createModuleLoader() {
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
return (modulePath: string) => {
const tryNative = shouldPreferNativeJiti(modulePath);
const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url);
const cacheKey = JSON.stringify({
tryNative,
aliasMap: Object.entries(aliasMap).toSorted(([a], [b]) => a.localeCompare(b)),
});
const cached = jitiLoaders.get(cacheKey);
if (cached) {
return cached;
}
const loader = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative,
});
jitiLoaders.set(cacheKey, loader);
return loader;
};
}
const loadModule = createModuleLoader();
function getContractSurfaceDiscoveryEnv(): NodeJS.ProcessEnv {
if (RUNNING_FROM_BUILT_ARTIFACT) {
return process.env;
}
return {
...process.env,
VITEST: process.env.VITEST || "1",
};
}
function matchesPreferredBasename(
basename: ContractSurfaceBasename,
preferredBasename: ContractSurfaceBasename | undefined,
): boolean {
if (!preferredBasename) {
return true;
}
return basename.replace(/\.[^.]+$/u, "") === preferredBasename.replace(/\.[^.]+$/u, "");
}
function resolveDistPreferredModulePath(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 resolveContractSurfaceModulePaths(
rootDir: string | undefined,
preferredBasename?: ContractSurfaceBasename,
): string[] {
if (typeof rootDir !== "string" || rootDir.length === 0) {
return [];
}
const modulePaths: string[] = [];
for (const basename of CONTRACT_SURFACE_BASENAMES) {
if (!matchesPreferredBasename(basename, preferredBasename)) {
continue;
}
const modulePath = path.join(rootDir, basename);
if (!fs.existsSync(modulePath)) {
continue;
}
modulePaths.push(resolveDistPreferredModulePath(modulePath));
}
return modulePaths;
}
function resolveSourceFirstContractSurfaceModulePaths(params: {
rootDir: string | undefined;
preferredBasename?: ContractSurfaceBasename;
}): string[] {
if (typeof params.rootDir !== "string" || params.rootDir.length === 0) {
return [];
}
if (RUNNING_FROM_BUILT_ARTIFACT) {
return resolveContractSurfaceModulePaths(params.rootDir, params.preferredBasename);
}
const dirName = path.basename(path.resolve(params.rootDir));
const sourceRoot = path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions", dirName);
const modulePaths: string[] = [];
for (const basename of CONTRACT_SURFACE_BASENAMES) {
if (!matchesPreferredBasename(basename, params.preferredBasename)) {
continue;
}
const sourceBaseName = basename.replace(/\.[^.]+$/u, "");
let sourceCandidatePath: string | null = null;
for (const ext of PUBLIC_SURFACE_SOURCE_EXTENSIONS) {
const candidate = path.join(sourceRoot, `${sourceBaseName}${ext}`);
if (fs.existsSync(candidate)) {
sourceCandidatePath = candidate;
break;
}
}
if (sourceCandidatePath) {
modulePaths.push(sourceCandidatePath);
continue;
}
const builtCandidates = resolveContractSurfaceModulePaths(params.rootDir, basename);
if (builtCandidates[0]) {
modulePaths.push(builtCandidates[0]);
}
}
return modulePaths;
}
function loadBundledChannelContractSurfaces(): unknown[] {
return loadBundledChannelContractSurfaceEntries().map((entry) => entry.surface);
}
function loadBundledChannelContractSurfaceEntries(): Array<{
pluginId: string;
surface: unknown;
}> {
const env = getContractSurfaceDiscoveryEnv();
const discovery = discoverOpenClawPlugins({ cache: false, env });
const manifestRegistry = loadPluginManifestRegistry({
cache: false,
env,
config: {},
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
const surfaces: Array<{ pluginId: string; surface: unknown }> = [];
for (const manifest of manifestRegistry.plugins) {
if (manifest.origin !== "bundled" || manifest.channels.length === 0) {
continue;
}
const modulePath = resolveSourceFirstContractSurfaceModulePaths({
rootDir: manifest.rootDir,
})[0];
if (!modulePath) {
continue;
}
try {
surfaces.push({
pluginId: manifest.id,
surface: loadModule(modulePath)(modulePath),
});
} catch {
continue;
}
}
return surfaces;
}
export function getBundledChannelContractSurfaces(): unknown[] {
cachedSurfaces ??= loadBundledChannelContractSurfaces();
return cachedSurfaces;
}
export function getBundledChannelContractSurfaceEntries(): Array<{
pluginId: string;
surface: unknown;
}> {
cachedSurfaceEntries ??= loadBundledChannelContractSurfaceEntries();
return cachedSurfaceEntries;
}
export function getBundledChannelContractSurfaceModule<T = unknown>(params: {
pluginId: string;
preferredBasename?: ContractSurfaceBasename;
}): T | null {
const cacheKey = `${params.pluginId}:${params.preferredBasename ?? "*"}`;
if (cachedPreferredSurfaceModules.has(cacheKey)) {
return (cachedPreferredSurfaceModules.get(cacheKey) ?? null) as T | null;
}
const env = getContractSurfaceDiscoveryEnv();
const discovery = discoverOpenClawPlugins({ cache: false, env });
const manifestRegistry = loadPluginManifestRegistry({
cache: false,
env,
config: {},
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
const manifest = manifestRegistry.plugins.find(
(entry) =>
entry.origin === "bundled" && entry.channels.length > 0 && entry.id === params.pluginId,
);
if (!manifest) {
cachedPreferredSurfaceModules.set(cacheKey, null);
return null;
}
const modulePath = resolveSourceFirstContractSurfaceModulePaths({
rootDir: manifest.rootDir,
preferredBasename: params.preferredBasename,
})[0];
if (!modulePath) {
cachedPreferredSurfaceModules.set(cacheKey, null);
return null;
}
try {
const module = loadModule(modulePath)(modulePath) as T;
cachedPreferredSurfaceModules.set(cacheKey, module);
return module;
} catch {
cachedPreferredSurfaceModules.set(cacheKey, null);
return null;
}
}

View File

@@ -1,22 +1,9 @@
import type { LegacyConfigRule } from "../../config/legacy.shared.js";
import type { OpenClawConfig } from "../../config/types.js";
import { getBundledChannelContractSurfaces } from "./contract-surfaces.js";
type ChannelLegacyConfigSurface = {
legacyConfigRules?: LegacyConfigRule[];
normalizeCompatibilityConfig?: (params: { cfg: OpenClawConfig }) => {
config: OpenClawConfig;
changes: string[];
warnings?: string[];
};
};
function getChannelLegacyConfigSurfaces(): ChannelLegacyConfigSurface[] {
return getBundledChannelContractSurfaces() as ChannelLegacyConfigSurface[];
}
import { listBootstrapChannelPlugins } from "./bootstrap-registry.js";
export function collectChannelLegacyConfigRules(): LegacyConfigRule[] {
return getChannelLegacyConfigSurfaces().flatMap((surface) => surface.legacyConfigRules ?? []);
return listBootstrapChannelPlugins().flatMap((plugin) => plugin.doctor?.legacyConfigRules ?? []);
}
export function applyChannelDoctorCompatibilityMigrations(cfg: Record<string, unknown>): {
@@ -25,8 +12,8 @@ export function applyChannelDoctorCompatibilityMigrations(cfg: Record<string, un
} {
let nextCfg = cfg as OpenClawConfig & Record<string, unknown>;
const changes: string[] = [];
for (const surface of getChannelLegacyConfigSurfaces()) {
const mutation = surface.normalizeCompatibilityConfig?.({ cfg: nextCfg });
for (const plugin of listBootstrapChannelPlugins()) {
const mutation = plugin.doctor?.normalizeCompatibilityConfig?.({ cfg: nextCfg });
if (!mutation || mutation.changes.length === 0) {
continue;
}

View File

@@ -1,53 +1,10 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { z, type ZodType } from "zod";
import type { OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { getBootstrapChannelPlugin } from "./bootstrap-registry.js";
import type { ChannelSetupAdapter } from "./types.adapters.js";
import type { ChannelSetupInput } from "./types.core.js";
const SETUP_PROMOTION_SURFACE_MODULE_BASENAMES = [
"contract-surfaces.ts",
"contract-surfaces.js",
] as const;
type SetupPromotionRuntimeModule = Pick<
typeof import("./contract-surfaces.js"),
"getBundledChannelContractSurfaceEntries"
>;
let cachedSetupPromotionRuntimeModule: SetupPromotionRuntimeModule | null = null;
export function clearSetupPromotionRuntimeModuleCache(): void {
cachedSetupPromotionRuntimeModule = null;
}
function resolveSetupPromotionRuntimeModulePath(): string {
for (const basename of SETUP_PROMOTION_SURFACE_MODULE_BASENAMES) {
const candidatePath = fileURLToPath(new URL(basename, import.meta.url));
const resolvedPath = candidatePath.replace(
`${path.sep}dist-runtime${path.sep}`,
`${path.sep}dist${path.sep}`,
);
if (fs.existsSync(resolvedPath)) {
return resolvedPath;
}
if (fs.existsSync(candidatePath)) {
return candidatePath;
}
}
throw new Error("missing setup promotion runtime module");
}
function loadSetupPromotionRuntimeModule(): SetupPromotionRuntimeModule {
cachedSetupPromotionRuntimeModule ??= createJiti(import.meta.url)(
resolveSetupPromotionRuntimeModulePath(),
) as SetupPromotionRuntimeModule;
return cachedSetupPromotionRuntimeModule;
}
type ChannelSectionBase = {
name?: string;
defaultAccount?: string;
@@ -193,6 +150,8 @@ export function prepareScopedSetupConfig(params: {
});
}
export function clearSetupPromotionRuntimeModuleCache(): void {}
export function applySetupAccountConfigPatch(params: {
cfg: OpenClawConfig;
channelKey: string;
@@ -458,13 +417,11 @@ type ChannelSetupPromotionSurface = {
};
function getChannelSetupPromotionSurface(channelKey: string): ChannelSetupPromotionSurface | null {
const entry = loadSetupPromotionRuntimeModule()
.getBundledChannelContractSurfaceEntries()
.find((candidate) => candidate.pluginId === channelKey);
if (!entry || !entry.surface || typeof entry.surface !== "object") {
const setup = getBootstrapChannelPlugin(channelKey)?.setup;
if (!setup || typeof setup !== "object") {
return null;
}
return entry.surface as ChannelSetupPromotionSurface;
return setup as ChannelSetupPromotionSurface;
}
export function shouldMoveSingleAccountChannelKey(params: {

View File

@@ -13,6 +13,8 @@ import type {
import type { OutboundMediaAccess } from "../../media/load-options.js";
import type { PluginRuntime } from "../../plugins/runtime/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { ResolverContext, SecretDefaults } from "../../secrets/runtime-shared.js";
import type { SecretTargetRegistryEntry } from "../../secrets/target-registry-types.js";
import type { ConfigWriteTarget } from "./config-writes.js";
import type {
ChannelAccountSnapshot,
@@ -95,6 +97,11 @@ export type ChannelSetupAdapter = {
accountId: string;
input: ChannelSetupInput;
}) => string | null;
singleAccountKeysToMove?: readonly string[];
namedAccountPromotionKeys?: readonly string[];
resolveSingleAccountPromotionTarget?: (params: {
channel: Record<string, unknown>;
}) => string | undefined;
};
export type ChannelConfigAdapter<ResolvedAccount> = {
@@ -122,6 +129,7 @@ export type ChannelConfigAdapter<ResolvedAccount> = {
accountId?: string | null;
allowFrom: Array<string | number>;
}) => string[];
hasConfiguredState?: (params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv }) => boolean;
hasPersistedAuthState?: (params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv }) => boolean;
resolveDefaultTo?: (params: {
cfg: OpenClawConfig;
@@ -129,6 +137,20 @@ export type ChannelConfigAdapter<ResolvedAccount> = {
}) => string | undefined;
};
export type ChannelSecretsAdapter = {
secretTargetRegistryEntries?: readonly SecretTargetRegistryEntry[];
unsupportedSecretRefSurfacePatterns?: readonly string[];
collectUnsupportedSecretRefConfigCandidates?: (raw: unknown) => Array<{
path: string;
value: unknown;
}>;
collectRuntimeConfigAssignments?: (params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}) => void;
};
export type ChannelGroupAdapter = {
resolveRequireMention?: (params: ChannelGroupContext) => boolean | undefined;
resolveGroupIntroHint?: (params: ChannelGroupContext) => string | undefined;

View File

@@ -3,6 +3,7 @@ import type { TSchema } from "@sinclair/typebox";
import type { MsgContext } from "../../auto-reply/templating.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import type { OutboundMediaAccess } from "../../media/load-options.js";
import type { PollInput } from "../../polls.js";
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
@@ -404,10 +405,31 @@ export type ChannelThreadingToolContext = {
/** Channel-owned messaging helpers for target parsing, routing, and payload shaping. */
export type ChannelMessagingAdapter = {
normalizeTarget?: (raw: string) => string | undefined;
defaultMarkdownTableMode?: MarkdownTableMode;
normalizeExplicitSessionKey?: (params: {
sessionKey: string;
ctx: MsgContext;
}) => string | undefined;
deriveLegacySessionChatType?: (sessionKey: string) => "direct" | "group" | "channel" | undefined;
isLegacyGroupSessionKey?: (key: string) => boolean;
canonicalizeLegacySessionKey?: (params: {
key: string;
agentId: string;
}) => string | null | undefined;
resolveLegacyGroupSessionKey?: (ctx: MsgContext) => {
key: string;
channel: string;
id: string;
chatType: "group" | "channel";
} | null;
resolveInboundAttachmentRoots?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => string[];
resolveRemoteInboundAttachmentRoots?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => string[];
resolveInboundConversation?: (params: {
from?: string;
to?: string;
@@ -606,6 +628,14 @@ export type ChannelMessageActionAdapter = {
action: ChannelMessageActionName;
args: Record<string, unknown>;
};
messageActionTargetAliases?: Partial<
Record<
ChannelMessageActionName,
{
aliases: string[];
}
>
>;
requiresTrustedRequesterSender?: (params: {
action: ChannelMessageActionName;
toolContext?: ChannelThreadingToolContext;

View File

@@ -17,6 +17,7 @@ import type {
ChannelLifecycleAdapter,
ChannelOutboundAdapter,
ChannelPairingAdapter,
ChannelSecretsAdapter,
ChannelSecurityAdapter,
ChannelSetupAdapter,
ChannelStatusAdapter,
@@ -106,6 +107,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
elevated?: ChannelElevatedAdapter;
commands?: ChannelCommandAdapter;
lifecycle?: ChannelLifecycleAdapter;
secrets?: ChannelSecretsAdapter;
approvals?: ChannelApprovalAdapter;
allowlist?: ChannelAllowlistAdapter;
doctor?: ChannelDoctorAdapter;

View File

@@ -37,6 +37,7 @@ export type {
ChannelOutboundPayloadHint,
ChannelOutboundTargetRef,
ChannelAllowlistAdapter,
ChannelSecretsAdapter,
ChannelCommandConversationContext,
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listBundledChannelPlugins } from "../channels/plugins/bundled.js";
import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
@@ -463,7 +463,7 @@ function shouldRequireOAuthDir(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boo
if (!isRecord(channels)) {
return false;
}
for (const plugin of listBundledChannelPlugins()) {
for (const plugin of listBootstrapChannelPlugins()) {
if (plugin.config.hasPersistedAuthState?.({ cfg, env })) {
return true;
}

View File

@@ -1,13 +1,8 @@
import { hasMeaningfulChannelConfig } from "../channels/config-presence.js";
import { getBundledChannelContractSurfaceModule } from "../channels/plugins/contract-surfaces.js";
import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js";
import { isRecord } from "../utils.js";
import type { OpenClawConfig } from "./config.js";
type ChannelConfiguredSurface = {
hasConfiguredState?: (params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv }) => boolean;
hasPersistedAuthState?: (params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv }) => boolean;
};
function resolveChannelConfig(
cfg: OpenClawConfig,
channelId: string,
@@ -22,24 +17,17 @@ function isGenericChannelConfigured(cfg: OpenClawConfig, channelId: string): boo
return hasMeaningfulChannelConfig(entry);
}
function getChannelConfiguredSurface(channelId: string): ChannelConfiguredSurface | null {
return getBundledChannelContractSurfaceModule<ChannelConfiguredSurface>({
pluginId: channelId,
preferredBasename: "contract-surfaces.ts",
});
}
export function isChannelConfigured(
cfg: OpenClawConfig,
channelId: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const surface = getChannelConfiguredSurface(channelId);
const pluginConfigured = surface?.hasConfiguredState?.({ cfg, env });
const plugin = getBootstrapChannelPlugin(channelId);
const pluginConfigured = plugin?.config?.hasConfiguredState?.({ cfg, env });
if (pluginConfigured) {
return true;
}
const pluginPersistedAuthState = surface?.hasPersistedAuthState?.({ cfg, env });
const pluginPersistedAuthState = plugin?.config?.hasPersistedAuthState?.({ cfg, env });
if (pluginPersistedAuthState) {
return true;
}

View File

@@ -1,4 +1,4 @@
import { getBundledChannelContractSurfaceEntries } from "../channels/plugins/contract-surfaces.js";
import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
import { resolveAccountEntry } from "../routing/account-lookup.js";
import { normalizeAccountId } from "../routing/session-key.js";
@@ -15,17 +15,12 @@ type MarkdownConfigSection = MarkdownConfigEntry & {
accounts?: Record<string, MarkdownConfigEntry>;
};
type ChannelMarkdownTableSurface = {
defaultMarkdownTableMode?: MarkdownTableMode;
};
function buildDefaultTableModes(): Map<string, MarkdownTableMode> {
return new Map(
getBundledChannelContractSurfaceEntries()
.flatMap(({ pluginId, surface }) => {
const defaultMarkdownTableMode = (surface as ChannelMarkdownTableSurface)
.defaultMarkdownTableMode;
return defaultMarkdownTableMode ? [[pluginId, defaultMarkdownTableMode] as const] : [];
listBootstrapChannelPlugins()
.flatMap((plugin) => {
const defaultMarkdownTableMode = plugin.messaging?.defaultMarkdownTableMode;
return defaultMarkdownTableMode ? [[plugin.id, defaultMarkdownTableMode] as const] : [];
})
.toSorted(([left], [right]) => left.localeCompare(right)),
);

View File

@@ -1,5 +1,5 @@
import type { MsgContext } from "../../auto-reply/templating.js";
import { getBundledChannelContractSurfaces } from "../../channels/plugins/contract-surfaces.js";
import { listBootstrapChannelPlugins } from "../../channels/plugins/bootstrap-registry.js";
import { normalizeHyphenSlug } from "../../shared/string-normalization.js";
import { listDeliverableMessageChannels } from "../../utils/message-channel.js";
import type { GroupKeyResolution } from "./types.js";
@@ -11,8 +11,10 @@ type LegacyGroupSessionSurface = {
};
function resolveLegacyGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null {
for (const surface of getBundledChannelContractSurfaces() as LegacyGroupSessionSurface[]) {
const resolved = surface.resolveLegacyGroupSessionKey?.(ctx);
for (const plugin of listBootstrapChannelPlugins()) {
const resolved = (
plugin.messaging as LegacyGroupSessionSurface | undefined
)?.resolveLegacyGroupSessionKey?.(ctx);
if (resolved) {
return resolved;
}

View File

@@ -1,4 +1,4 @@
import { getBundledChannelContractSurfaceEntries } from "../../channels/plugins/contract-surfaces.js";
import { listBootstrapChannelPlugins } from "../../channels/plugins/bootstrap-registry.js";
import type { ChannelMessageActionName } from "../../channels/plugins/types.js";
export type MessageActionTargetMode = "to" | "channelId" | "none";
@@ -78,20 +78,6 @@ const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, ActionTarg
leaveGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
};
type ChannelMessageActionAliasSurface = {
messageActionTargetAliases?: Partial<Record<ChannelMessageActionName, ActionTargetAliasSpec>>;
};
function listChannelMessageActionAliasSurfaces(): Array<{
pluginId: string;
surface: ChannelMessageActionAliasSurface;
}> {
return getBundledChannelContractSurfaceEntries() as Array<{
pluginId: string;
surface: ChannelMessageActionAliasSurface;
}>;
}
function listActionTargetAliasSpecs(
action: ChannelMessageActionName,
channel?: string,
@@ -105,11 +91,11 @@ function listActionTargetAliasSpecs(
if (!normalizedChannel) {
return specs;
}
for (const entry of listChannelMessageActionAliasSurfaces()) {
if (entry.pluginId !== normalizedChannel) {
for (const plugin of listBootstrapChannelPlugins()) {
if (plugin.id !== normalizedChannel) {
continue;
}
const channelSpec = entry.surface.messageActionTargetAliases?.[action];
const channelSpec = plugin.actions?.messageActionTargetAliases?.[action];
if (channelSpec) {
specs.push(channelSpec);
}

View File

@@ -2,8 +2,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js";
import { listBundledChannelPlugins } from "../channels/plugins/bundled.js";
import { getBundledChannelContractSurfaces } from "../channels/plugins/contract-surfaces.js";
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
import type { OpenClawConfig } from "../config/config.js";
import {
@@ -79,9 +79,11 @@ type LegacySessionSurface = {
};
function getLegacySessionSurfaces(): LegacySessionSurface[] {
return getBundledChannelContractSurfaces().filter(
(surface): surface is LegacySessionSurface => Boolean(surface) && typeof surface === "object",
);
return listBootstrapChannelPlugins()
.map((plugin) => plugin.messaging)
.filter(
(surface): surface is LegacySessionSurface => Boolean(surface) && typeof surface === "object",
);
}
function isSurfaceGroupKey(key: string): boolean {

View File

@@ -1,41 +1,26 @@
import type { MsgContext } from "../auto-reply/templating.js";
import { getBundledChannelContractSurfaceEntries } from "../channels/plugins/contract-surfaces.js";
import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js";
import type { OpenClawConfig } from "../config/config.js";
type ChannelInboundMediaRootsSurface = {
resolveInboundAttachmentRoots?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => string[];
resolveRemoteInboundAttachmentRoots?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => string[];
};
function normalizeChannelId(value?: string | null): string | undefined {
const normalized = value?.trim().toLowerCase();
return normalized || undefined;
}
function findChannelMediaSurface(
channelId?: string | null,
): ChannelInboundMediaRootsSurface | undefined {
function findChannelMessagingAdapter(channelId?: string | null) {
const normalized = normalizeChannelId(channelId);
if (!normalized) {
return undefined;
}
return getBundledChannelContractSurfaceEntries().find(
(entry) => normalizeChannelId(entry.pluginId) === normalized,
)?.surface as ChannelInboundMediaRootsSurface | undefined;
return getBootstrapChannelPlugin(normalized)?.messaging;
}
export function resolveChannelInboundAttachmentRoots(params: {
cfg: OpenClawConfig;
ctx: MsgContext;
}): readonly string[] | undefined {
const surface = findChannelMediaSurface(params.ctx.Surface ?? params.ctx.Provider);
return surface?.resolveInboundAttachmentRoots?.({
const messaging = findChannelMessagingAdapter(params.ctx.Surface ?? params.ctx.Provider);
return messaging?.resolveInboundAttachmentRoots?.({
cfg: params.cfg,
accountId: params.ctx.AccountId,
});
@@ -45,8 +30,8 @@ export function resolveChannelRemoteInboundAttachmentRoots(params: {
cfg: OpenClawConfig;
ctx: MsgContext;
}): readonly string[] | undefined {
const surface = findChannelMediaSurface(params.ctx.Surface ?? params.ctx.Provider);
return surface?.resolveRemoteInboundAttachmentRoots?.({
const messaging = findChannelMessagingAdapter(params.ctx.Surface ?? params.ctx.Provider);
return messaging?.resolveRemoteInboundAttachmentRoots?.({
cfg: params.cfg,
accountId: params.ctx.AccountId,
});

View File

@@ -1,86 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { TELEGRAM_COMMAND_NAME_PATTERN as bundledTelegramCommandNamePattern } from "../../extensions/telegram/channel-config-api.ts";
import * as telegramCommandConfig from "./telegram-command-config.js";
type BundledChannelContractSurfaceParams = Parameters<
(typeof import("../channels/plugins/contract-surfaces.js"))["getBundledChannelContractSurfaceModule"]
>[0];
type TelegramCommandConfigContract = Pick<
typeof import("./telegram-command-config.js"),
| "TELEGRAM_COMMAND_NAME_PATTERN"
| "normalizeTelegramCommandName"
| "normalizeTelegramCommandDescription"
| "resolveTelegramCustomCommands"
>;
const getBundledChannelContractSurfaceModule = vi.fn<
(params: BundledChannelContractSurfaceParams) => TelegramCommandConfigContract | null
>(() => null);
vi.mock("../channels/plugins/contract-surfaces.js", () => ({
getBundledChannelContractSurfaceModule,
}));
async function loadTelegramCommandConfig() {
vi.resetModules();
getBundledChannelContractSurfaceModule.mockClear();
return import("./telegram-command-config.js");
}
describe("telegram command config fallback", () => {
it("keeps the fallback regex in parity with the bundled telegram contract", async () => {
const telegramCommandConfig = await loadTelegramCommandConfig();
describe("telegram command config", () => {
it("keeps the regex in parity with the bundled telegram contract", () => {
expect(telegramCommandConfig.TELEGRAM_COMMAND_NAME_PATTERN.toString()).toBe(
bundledTelegramCommandNamePattern.toString(),
);
});
it("keeps import-time regex access side-effect free", async () => {
const telegramCommandConfig = await loadTelegramCommandConfig();
expect(getBundledChannelContractSurfaceModule).not.toHaveBeenCalled();
expect(telegramCommandConfig.TELEGRAM_COMMAND_NAME_PATTERN.test("hello_world")).toBe(true);
expect(getBundledChannelContractSurfaceModule).not.toHaveBeenCalled();
});
it("lazy-loads the contract pattern only when callers opt in", async () => {
const contractPattern = /^[a-z]+$/;
getBundledChannelContractSurfaceModule.mockReturnValueOnce({
TELEGRAM_COMMAND_NAME_PATTERN: contractPattern,
normalizeTelegramCommandName: (value: string) => `contract:${value.trim().toLowerCase()}`,
normalizeTelegramCommandDescription: (value: string) => `desc:${value.trim()}`,
resolveTelegramCustomCommands: () => ({
commands: [{ command: "from_contract", description: "from contract" }],
issues: [],
}),
});
const telegramCommandConfig = await loadTelegramCommandConfig();
expect(getBundledChannelContractSurfaceModule).not.toHaveBeenCalled();
expect(telegramCommandConfig.TELEGRAM_COMMAND_NAME_PATTERN.test("hello_world")).toBe(true);
expect(getBundledChannelContractSurfaceModule).not.toHaveBeenCalled();
expect(telegramCommandConfig.getTelegramCommandNamePattern()).toBe(contractPattern);
expect(getBundledChannelContractSurfaceModule).toHaveBeenCalledTimes(1);
it("exposes the same regex via the helper", () => {
expect(telegramCommandConfig.getTelegramCommandNamePattern()).toBe(
telegramCommandConfig.getTelegramCommandNamePattern(),
telegramCommandConfig.TELEGRAM_COMMAND_NAME_PATTERN,
);
expect(telegramCommandConfig.normalizeTelegramCommandName(" Hello ")).toBe("contract:hello");
expect(telegramCommandConfig.normalizeTelegramCommandDescription(" hi ")).toBe("desc:hi");
expect(
telegramCommandConfig.resolveTelegramCustomCommands({
commands: [{ command: "/ignored", description: "ignored" }],
}),
).toEqual({
commands: [{ command: "from_contract", description: "from contract" }],
issues: [],
});
expect(getBundledChannelContractSurfaceModule).toHaveBeenCalledTimes(1);
expect(telegramCommandConfig.TELEGRAM_COMMAND_NAME_PATTERN.test("hello_world")).toBe(true);
});
it("keeps command validation available when the bundled contract surface is unavailable", async () => {
const telegramCommandConfig = await loadTelegramCommandConfig();
it("validates and normalizes commands", () => {
expect(telegramCommandConfig.TELEGRAM_COMMAND_NAME_PATTERN.test("hello_world")).toBe(true);
expect(telegramCommandConfig.normalizeTelegramCommandName("/Hello-World")).toBe("hello_world");
expect(telegramCommandConfig.normalizeTelegramCommandDescription(" hi ")).toBe("hi");
@@ -114,6 +50,5 @@ describe("telegram command config fallback", () => {
},
],
});
expect(getBundledChannelContractSurfaceModule).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,3 @@
import { getBundledChannelContractSurfaceModule } from "../channels/plugins/contract-surfaces.js";
export type TelegramCustomCommandInput = {
command?: string | null;
description?: string | null;
@@ -10,26 +8,9 @@ export type TelegramCustomCommandIssue = {
field: "command" | "description";
message: string;
};
const TELEGRAM_COMMAND_NAME_PATTERN_VALUE = /^[a-z0-9_]{1,32}$/;
type TelegramCommandConfigContract = {
TELEGRAM_COMMAND_NAME_PATTERN: RegExp;
normalizeTelegramCommandName: (value: string) => string;
normalizeTelegramCommandDescription: (value: string) => string;
resolveTelegramCustomCommands: (params: {
commands?: TelegramCustomCommandInput[] | null;
reservedCommands?: Set<string>;
checkReserved?: boolean;
checkDuplicates?: boolean;
}) => {
commands: Array<{ command: string; description: string }>;
issues: TelegramCustomCommandIssue[];
};
};
const FALLBACK_TELEGRAM_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
let cachedTelegramCommandConfigContract: TelegramCommandConfigContract | null = null;
function fallbackNormalizeTelegramCommandName(value: string): string {
function normalizeTelegramCommandNameImpl(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
@@ -38,11 +19,11 @@ function fallbackNormalizeTelegramCommandName(value: string): string {
return withoutSlash.trim().toLowerCase().replace(/-/g, "_");
}
function fallbackNormalizeTelegramCommandDescription(value: string): string {
function normalizeTelegramCommandDescriptionImpl(value: string): string {
return value.trim();
}
function fallbackResolveTelegramCustomCommands(params: {
function resolveTelegramCustomCommandsImpl(params: {
commands?: TelegramCustomCommandInput[] | null;
reservedCommands?: Set<string>;
checkReserved?: boolean;
@@ -61,7 +42,7 @@ function fallbackResolveTelegramCustomCommands(params: {
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
const normalized = fallbackNormalizeTelegramCommandName(String(entry?.command ?? ""));
const normalized = normalizeTelegramCommandNameImpl(String(entry?.command ?? ""));
if (!normalized) {
issues.push({
index,
@@ -70,7 +51,7 @@ function fallbackResolveTelegramCustomCommands(params: {
});
continue;
}
if (!FALLBACK_TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
if (!TELEGRAM_COMMAND_NAME_PATTERN_VALUE.test(normalized)) {
issues.push({
index,
field: "command",
@@ -94,9 +75,7 @@ function fallbackResolveTelegramCustomCommands(params: {
});
continue;
}
const description = fallbackNormalizeTelegramCommandDescription(
String(entry?.description ?? ""),
);
const description = normalizeTelegramCommandDescriptionImpl(String(entry?.description ?? ""));
if (!description) {
issues.push({
index,
@@ -114,38 +93,18 @@ function fallbackResolveTelegramCustomCommands(params: {
return { commands: resolved, issues };
}
const FALLBACK_TELEGRAM_COMMAND_CONFIG_CONTRACT: TelegramCommandConfigContract = {
TELEGRAM_COMMAND_NAME_PATTERN: FALLBACK_TELEGRAM_COMMAND_NAME_PATTERN,
normalizeTelegramCommandName: fallbackNormalizeTelegramCommandName,
normalizeTelegramCommandDescription: fallbackNormalizeTelegramCommandDescription,
resolveTelegramCustomCommands: fallbackResolveTelegramCustomCommands,
};
function loadTelegramCommandConfigContract(): TelegramCommandConfigContract {
cachedTelegramCommandConfigContract ??=
getBundledChannelContractSurfaceModule<TelegramCommandConfigContract>({
pluginId: "telegram",
preferredBasename: "contract-surfaces.ts",
}) ?? FALLBACK_TELEGRAM_COMMAND_CONFIG_CONTRACT;
return cachedTelegramCommandConfigContract;
}
export function getTelegramCommandNamePattern(): RegExp {
return loadTelegramCommandConfigContract().TELEGRAM_COMMAND_NAME_PATTERN;
return TELEGRAM_COMMAND_NAME_PATTERN_VALUE;
}
/**
* @deprecated Use `getTelegramCommandNamePattern()` when you need the live
* bundled contract value. This export remains an import-time-safe fallback.
*/
export const TELEGRAM_COMMAND_NAME_PATTERN = FALLBACK_TELEGRAM_COMMAND_NAME_PATTERN;
export const TELEGRAM_COMMAND_NAME_PATTERN = TELEGRAM_COMMAND_NAME_PATTERN_VALUE;
export function normalizeTelegramCommandName(value: string): string {
return loadTelegramCommandConfigContract().normalizeTelegramCommandName(value);
return normalizeTelegramCommandNameImpl(value);
}
export function normalizeTelegramCommandDescription(value: string): string {
return loadTelegramCommandConfigContract().normalizeTelegramCommandDescription(value);
return normalizeTelegramCommandDescriptionImpl(value);
}
export function resolveTelegramCustomCommands(params: {
@@ -157,5 +116,5 @@ export function resolveTelegramCustomCommands(params: {
commands: Array<{ command: string; description: string }>;
issues: TelegramCustomCommandIssue[];
} {
return loadTelegramCommandConfigContract().resolveTelegramCustomCommands(params);
return resolveTelegramCustomCommandsImpl(params);
}

View File

@@ -1,32 +1,29 @@
import { getBundledChannelContractSurfaceModule } from "../channels/plugins/contract-surfaces.js";
type TelegramCommandUiContract = {
buildCommandsPaginationKeyboard: (
currentPage: number,
totalPages: number,
agentId?: string,
) => Array<Array<{ text: string; callback_data: string }>>;
};
function loadTelegramCommandUiContract(): TelegramCommandUiContract {
const contract = getBundledChannelContractSurfaceModule<TelegramCommandUiContract>({
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<Array<{ text: string; callback_data: string }>> {
return loadTelegramCommandUiContract().buildCommandsPaginationKeyboard(
currentPage,
totalPages,
agentId,
);
const buttons: Array<{ text: string; callback_data: string }> = [];
const suffix = agentId ? `:${agentId}` : "";
if (currentPage > 1) {
buttons.push({
text: "◀ Prev",
callback_data: `commands_page_${currentPage - 1}${suffix}`,
});
}
buttons.push({
text: `${currentPage}/${totalPages}`,
callback_data: `commands_page_noop${suffix}`,
});
if (currentPage < totalPages) {
buttons.push({
text: "Next ▶",
callback_data: `commands_page_${currentPage + 1}${suffix}`,
});
}
return [buttons];
}

View File

@@ -1,25 +1,13 @@
import { getBundledChannelContractSurfaces } from "../channels/plugins/contract-surfaces.js";
import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js";
import type { OpenClawConfig } from "../config/config.js";
import { type ResolverContext, type SecretDefaults } from "./runtime-shared.js";
type ChannelRuntimeConfigCollectorSurface = {
collectRuntimeConfigAssignments?: (params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}) => void;
};
function listChannelRuntimeConfigCollectorSurfaces(): ChannelRuntimeConfigCollectorSurface[] {
return getBundledChannelContractSurfaces() as ChannelRuntimeConfigCollectorSurface[];
}
export function collectChannelConfigAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
for (const surface of listChannelRuntimeConfigCollectorSurfaces()) {
surface.collectRuntimeConfigAssignments?.(params);
for (const plugin of listBootstrapChannelPlugins()) {
plugin.secrets?.collectRuntimeConfigAssignments?.(params);
}
}

View File

@@ -1,16 +1,12 @@
import { getBundledChannelContractSurfaces } from "../channels/plugins/contract-surfaces.js";
import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js";
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
type ChannelSecretTargetRegistrySurface = {
secretTargetRegistryEntries?: readonly SecretTargetRegistryEntry[];
};
const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret
const SIBLING_REF_SHAPE = "sibling_ref"; // pragma: allowlist secret
function listChannelSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] {
return (getBundledChannelContractSurfaces() as ChannelSecretTargetRegistrySurface[]).flatMap(
(surface) => surface.secretTargetRegistryEntries ?? [],
return listBootstrapChannelPlugins().flatMap(
(plugin) => plugin.secrets?.secretTargetRegistryEntries ?? [],
);
}

View File

@@ -1,14 +1,6 @@
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js";
import { isRecord } from "../utils.js";
type ChannelUnsupportedSecretRefSurface = {
unsupportedSecretRefSurfacePatterns?: readonly string[];
collectUnsupportedSecretRefConfigCandidates?: (
raw: unknown,
) => UnsupportedSecretRefConfigCandidate[];
};
const CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [
"commands.ownerDisplaySecret",
"hooks.token",
@@ -17,42 +9,9 @@ const CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [
"auth-profiles.oauth.*",
] as const;
type BundledChannelContractSurfacesModule = {
getBundledChannelContractSurfaces?: () => unknown[];
};
const CONTRACT_SURFACES_MODULE_PATH = fileURLToPath(
new URL("../channels/plugins/contract-surfaces.js", import.meta.url),
);
let bundledChannelContractSurfacesModule: BundledChannelContractSurfacesModule | null | undefined;
let bundledChannelContractSurfacesLoader: ReturnType<typeof createJiti> | undefined;
function loadBundledChannelContractSurfacesModule(): BundledChannelContractSurfacesModule | null {
if (bundledChannelContractSurfacesModule !== undefined) {
return bundledChannelContractSurfacesModule;
}
try {
bundledChannelContractSurfacesLoader ??= createJiti(import.meta.url, { interopDefault: true });
bundledChannelContractSurfacesModule = bundledChannelContractSurfacesLoader(
CONTRACT_SURFACES_MODULE_PATH,
) as BundledChannelContractSurfacesModule;
} catch {
bundledChannelContractSurfacesModule = null;
}
return bundledChannelContractSurfacesModule;
}
function listChannelUnsupportedSecretRefSurfaces(): ChannelUnsupportedSecretRefSurface[] {
const module = loadBundledChannelContractSurfacesModule();
if (typeof module?.getBundledChannelContractSurfaces !== "function") {
return [];
}
return module.getBundledChannelContractSurfaces() as ChannelUnsupportedSecretRefSurface[];
}
function collectChannelUnsupportedSecretRefSurfacePatterns(): string[] {
return listChannelUnsupportedSecretRefSurfaces().flatMap(
(surface) => surface.unsupportedSecretRefSurfacePatterns ?? [],
return listBootstrapChannelPlugins().flatMap(
(plugin) => plugin.secrets?.unsupportedSecretRefSurfacePatterns ?? [],
);
}
@@ -115,8 +74,8 @@ export function collectUnsupportedSecretRefConfigCandidates(
}
if (isRecord(raw.channels)) {
for (const surface of listChannelUnsupportedSecretRefSurfaces()) {
const channelCandidates = surface.collectUnsupportedSecretRefConfigCandidates?.(raw);
for (const plugin of listBootstrapChannelPlugins()) {
const channelCandidates = plugin.secrets?.collectUnsupportedSecretRefConfigCandidates?.(raw);
if (!channelCandidates?.length) {
continue;
}

View File

@@ -1,16 +1,8 @@
import { getBundledChannelContractSurfaces } from "../channels/plugins/contract-surfaces.js";
import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js";
import { parseAgentSessionKey } from "./session-key-utils.js";
export type SessionKeyChatType = "direct" | "group" | "channel" | "unknown";
type LegacySessionChatTypeSurface = {
deriveLegacySessionChatType?: (sessionKey: string) => "direct" | "group" | "channel" | undefined;
};
function listLegacySessionChatTypeSurfaces(): LegacySessionChatTypeSurface[] {
return getBundledChannelContractSurfaces() as LegacySessionChatTypeSurface[];
}
function deriveBuiltInLegacySessionChatType(
scopedSessionKey: string,
): SessionKeyChatType | undefined {
@@ -52,8 +44,8 @@ export function deriveSessionChatType(sessionKey: string | undefined | null): Se
if (builtInLegacy) {
return builtInLegacy;
}
for (const surface of listLegacySessionChatTypeSurfaces()) {
const derived = surface.deriveLegacySessionChatType?.(scoped);
for (const plugin of listBootstrapChannelPlugins()) {
const derived = plugin.messaging?.deriveLegacySessionChatType?.(scoped);
if (derived) {
return derived;
}