diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index b71b57ea3193..d1881f20eeae 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -387,6 +387,10 @@ "source": "WeChat", "target": "微信" }, + { + "source": "WeCom", + "target": "企业微信" + }, { "source": "Weixin", "target": "微信" diff --git a/docs/.i18n/glossary.zh-TW.json b/docs/.i18n/glossary.zh-TW.json index 6cc24e2abd12..edccfe2e4675 100644 --- a/docs/.i18n/glossary.zh-TW.json +++ b/docs/.i18n/glossary.zh-TW.json @@ -135,6 +135,10 @@ "source": "WeChat", "target": "微信" }, + { + "source": "WeCom", + "target": "企業微信" + }, { "source": "Weixin", "target": "微信" diff --git a/docs/channels/index.md b/docs/channels/index.md index fc8b05d00ce3..526f0f35ca75 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -9,43 +9,54 @@ title: "Chat channels" OpenClaw can talk to you on any chat app you already use. Each channel connects via the Gateway. Text is supported everywhere; media and reactions vary by channel. -Telegram and the WebChat UI ship with the core install. Channels marked -"official plugin" install with one command (`openclaw plugins install @openclaw/`) -or on demand during `openclaw onboard` / `openclaw channels add`, then need a Gateway -restart. "External plugin" channels are maintained outside the OpenClaw repo. +Entries marked "bundled plugin" or "included in core" ship with the core +install. Channels marked "official plugin" install with one command +(`openclaw plugins install @openclaw/`) or on demand during +`openclaw onboard` / `openclaw channels add`, then need a Gateway restart. +"External plugin" channels are maintained outside the OpenClaw repo. ## Supported channels -- [Buzz](/channels/buzz) - Buzz team rooms with threaded replies (official plugin). -- [Discord](/channels/discord) - Discord Bot API + Gateway; supports servers, channels, and DMs (official plugin). -- [Feishu](/channels/feishu) - Feishu/Lark bot via WebSocket (official plugin). -- [Google Chat](/channels/googlechat) - Google Chat API app via HTTP webhook (official plugin). -- [iMessage](/channels/imessage) - Native macOS integration via the `imsg` bridge on a signed-in Mac (or SSH wrapper when the Gateway runs elsewhere), including private API actions for replies, tapbacks, effects, attachments, and group management (official plugin). -- [IRC](/channels/irc) - Classic IRC servers; channels + DMs with pairing/allowlist controls (official plugin). -- [LINE](/channels/line) - LINE Messaging API bot (official plugin). -- [Matrix](/channels/matrix) - Matrix protocol (official plugin). -- [Mattermost](/channels/mattermost) - Bot API + WebSocket; channels, groups, DMs (official plugin). -- [Microsoft Teams](/channels/msteams) - Bot Framework; enterprise support (official plugin). -- [Nextcloud Talk](/channels/nextcloud-talk) - Self-hosted chat via Nextcloud Talk (official plugin). -- [Nostr](/channels/nostr) - Decentralized DMs via NIP-04 (official plugin). -- [QQ Bot](/channels/qqbot) - QQ Bot API; private chat, group chat, and rich media (official plugin). -- [Reef](/channels/reef) - Guarded, end-to-end-encrypted claw-to-claw messaging between OpenClaw agents of different people (bundled plugin). -- [Raft](/channels/raft) - Raft CLI wake bridge for human and agent collaboration (official plugin). -- [Signal](/channels/signal) - signal-cli; privacy-focused (official plugin). -- [Slack](/channels/slack) - Bolt SDK; workspace apps (official plugin). -- [SMS](/channels/sms) - Twilio-backed SMS through the Gateway webhook (official plugin). -- [Synology Chat](/channels/synology-chat) - Synology NAS Chat via outgoing+incoming webhooks (official plugin). -- [Telegram](/channels/telegram) - Included in core. Bot API via grammY; supports groups. -- [Tlon](/channels/tlon) - Urbit-based messenger (official plugin). -- [Twitch](/channels/twitch) - Twitch chat via IRC connection (official plugin). + + + +- [Buzz](/channels/buzz) - Connect OpenClaw agents to Buzz rooms (official plugin). +- [ClickClack](/channels/clickclack) - ClickClack bot-token channel setup and target syntax (official plugin). +- [Discord](/channels/discord) - Discord bot setup, config keys, components, voice, and troubleshooting (official plugin). +- [Feishu](/channels/feishu) - Feishu bot overview, features, and configuration (official plugin). +- [Google Chat](/channels/googlechat) - Google Chat app support status, capabilities, and configuration (official plugin). +- [iMessage](/channels/imessage) - Native iMessage support via imsg (JSON-RPC over stdio), with private API actions for replies, tapbacks, effects, polls, attachments, and group management. Preferred for new OpenClaw iMessage setups when host requirements fit (official plugin). +- [IRC](/channels/irc) - IRC plugin setup, access controls, and troubleshooting (official plugin). +- [LINE](/channels/line) - LINE Messaging API plugin setup, config, and usage (official plugin). +- [Matrix](/channels/matrix) - Matrix support status, setup, and configuration examples (official plugin). +- [Mattermost](/channels/mattermost) - Mattermost bot setup and OpenClaw config (official plugin). +- [Microsoft Teams](/channels/msteams) - Microsoft Teams bot support status, capabilities, and configuration (official plugin). +- [Nextcloud Talk](/channels/nextcloud-talk) - Nextcloud Talk support status, capabilities, and configuration (official plugin). +- [Nostr](/channels/nostr) - Nostr DM channel via NIP-04 encrypted messages (official plugin). +- [QQ bot](/channels/qqbot) - QQ Bot setup, config, and usage (official plugin). +- [Raft](/channels/raft) - Raft External Agent support through the Raft CLI wake bridge (official plugin). +- [Reef](/channels/reef) - Reef channel setup: guarded, end-to-end-encrypted messaging between OpenClaw agents of different people (bundled plugin). +- [Signal](/channels/signal) - Signal support via signal-cli (native daemon or bbernhard container), setup paths, and number model (official plugin). +- [Slack](/channels/slack) - Slack setup and runtime behavior (Socket Mode, HTTP Request URLs, and relay mode) (official plugin). +- [SMS](/channels/sms) - Twilio SMS channel setup, access controls, and webhook configuration (official plugin). +- [Synology Chat](/channels/synology-chat) - Synology Chat webhook setup and OpenClaw config (official plugin). +- [Telegram](/channels/telegram) - Telegram bot support status, capabilities, and configuration (bundled plugin). +- [Tlon](/channels/tlon) - Tlon/Urbit support status, capabilities, and configuration (official plugin). +- [Twitch](/channels/twitch) - Twitch chat bot: install, credentials, access control, token refresh (official plugin). +- [WebChat](/web/webchat) - Native and Control UI WebChat usage over the Gateway WebSocket (included in core). +- [WeChat](/channels/wechat) - WeChat channel setup through the external openclaw-weixin plugin (external plugin). +- [WeCom](/channels/wecom) - Install the official WeCom plugin and find its versioned setup documentation (external plugin). +- [WhatsApp](/channels/whatsapp) - WhatsApp channel support, access controls, delivery behavior, and operations (official plugin). +- [Yuanbao](/channels/yuanbao) - Yuanbao bot overview, features, and configuration (external plugin). +- [Zalo](/channels/zalo) - Zalo bot support status, capabilities, and configuration (official plugin). +- [Zalo ClawBot](/channels/zaloclawbot) - Zalo ClawBot channel setup through the external openclaw-zaloclawbot plugin (external plugin). +- [Zalo personal](/channels/zalouser) - Zalo personal account support via native zca-js (QR login), capabilities, and configuration (official plugin). + + + +### Related communication plugins + - [Voice Call](/plugins/voice-call) - Telephony via Plivo, Telnyx, or Twilio (official plugin). -- [WebChat](/web/webchat) - Included in core. Gateway WebChat UI over WebSocket. -- [WeChat](/channels/wechat) - Tencent iLink bot via QR login; private chats only (external plugin). -- [WhatsApp](/channels/whatsapp) - Most popular; uses Baileys and requires QR pairing (official plugin). -- [Yuanbao](/channels/yuanbao) - Tencent Yuanbao bot (external plugin). -- [Zalo](/channels/zalo) - Zalo Bot API; Vietnam's popular messenger (official plugin). -- [Zalo ClawBot](/channels/zaloclawbot) - Personal Zalo assistant via QR login; owner-bound (external plugin). -- [Zalo Personal](/channels/zalouser) - Zalo personal account via QR login (official plugin). ## Delivery notes diff --git a/docs/channels/wecom.md b/docs/channels/wecom.md new file mode 100644 index 000000000000..6ad60323b3f7 --- /dev/null +++ b/docs/channels/wecom.md @@ -0,0 +1,34 @@ +--- +summary: "Install the official WeCom plugin and find its versioned setup documentation" +read_when: + - You want to connect OpenClaw to WeCom + - You need the supported WeCom plugin and its setup documentation +title: "WeCom" +--- + +OpenClaw exposes WeCom through the external +`@wecom/wecom-openclaw-plugin` package maintained by the Tencent WeCom team. +The plugin is listed in OpenClaw's official channel catalog but is not bundled +with the core install. + +## Install + +```bash +openclaw channels add --channel wecom +openclaw gateway restart +openclaw channels status --channel wecom +``` + +The OpenClaw catalog installs an exact version of +`@wecom/wecom-openclaw-plugin`. + +## Configure + +WeCom credentials, connection modes, callback routes, and access-control +behavior belong to the external plugin and can change independently of +OpenClaw. Follow the +[package documentation](https://www.npmjs.com/package/@wecom/wecom-openclaw-plugin) +for the installed release before configuring the channel. + +When upgrading the plugin independently, keep using the documentation for the +installed version. diff --git a/docs/docs.json b/docs/docs.json index 7fe4d8ea8527..2650098aa308 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1154,6 +1154,7 @@ "channels/discord", "channels/discord-activities", "channels/slack", + "channels/sms", "channels/telegram", "channels/whatsapp", "channels/signal", @@ -1170,6 +1171,8 @@ { "group": "Developer and self-hosted", "pages": [ + "channels/buzz", + "channels/clickclack", "channels/irc", "channels/mattermost", "channels/nextcloud-talk", @@ -1186,6 +1189,7 @@ "pages": [ "channels/line", "channels/wechat", + "channels/wecom", "channels/qqbot", "channels/feishu", "channels/yuanbao", @@ -1205,8 +1209,7 @@ "channels/broadcast-groups", "channels/channel-routing", "channels/location", - "channels/troubleshooting", - "channels/qa-channel" + "channels/troubleshooting" ] } ] diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 3fe0f12e0cf6..de7770aec26f 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -1,5 +1,5 @@ --- -summary: "Loopback WebChat static host and Gateway WS usage for chat UI" +summary: "Native and Control UI WebChat usage over the Gateway WebSocket" read_when: - Debugging or configuring WebChat access title: "WebChat" diff --git a/scripts/generate-plugin-inventory-doc.mjs b/scripts/generate-plugin-inventory-doc.mjs index b24f1b594447..796c70e085f4 100644 --- a/scripts/generate-plugin-inventory-doc.mjs +++ b/scripts/generate-plugin-inventory-doc.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; +import { collectExcludedPackagedExtensionDirs } from "./lib/packaged-extension-dirs.mjs"; import { resolvePluginSurface } from "./lib/plugin-inventory-doc.mjs"; const DOC_PATH = "docs/plugins/plugin-inventory.md"; @@ -68,20 +69,6 @@ function fileExists(relativePath) { return fs.existsSync(path.join(ROOT, relativePath)); } -function collectExcludedPackagedExtensionDirs(rootPackageJson) { - const excluded = new Set(); - for (const entry of rootPackageJson.files ?? []) { - if (typeof entry !== "string") { - continue; - } - const match = /^!dist\/extensions\/([^/]+)\/\*\*$/u.exec(entry); - if (match?.[1]) { - excluded.add(match[1]); - } - } - return excluded; -} - function normalizeDocPath(value) { if (typeof value !== "string" || !value.startsWith("/")) { return null; diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index 84eaf6239969..5942910f20bc 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -2259,7 +2259,7 @@ "label": "WeCom", "selectionLabel": "WeCom(企业微信)", "detailLabel": "WeCom", - "docsPath": "/plugins/community#wecom", + "docsPath": "/channels/wecom", "docsLabel": "wecom", "blurb": "Enterprise messaging and documents, scheduling, task tools.", "aliases": [ @@ -2357,7 +2357,7 @@ "label": "Yuanbao", "selectionLabel": "Yuanbao (元宝)", "detailLabel": "Yuanbao", - "docsPath": "/plugins/community#yuanbao", + "docsPath": "/channels/yuanbao", "docsLabel": "yuanbao", "blurb": "Tencent Yuanbao AI assistant conversation channel.", "aliases": [ diff --git a/scripts/lib/official-external-channel-seed.json b/scripts/lib/official-external-channel-seed.json index bedd4673cc81..7290a071dc60 100644 --- a/scripts/lib/official-external-channel-seed.json +++ b/scripts/lib/official-external-channel-seed.json @@ -18,7 +18,7 @@ "label": "WeCom", "selectionLabel": "WeCom(企业微信)", "detailLabel": "WeCom", - "docsPath": "/plugins/community#wecom", + "docsPath": "/channels/wecom", "docsLabel": "wecom", "blurb": "Enterprise messaging and documents, scheduling, task tools.", "aliases": ["qywx", "wework", "enterprise-wechat"], @@ -59,7 +59,7 @@ "label": "Yuanbao", "selectionLabel": "Yuanbao (元宝)", "detailLabel": "Yuanbao", - "docsPath": "/plugins/community#yuanbao", + "docsPath": "/channels/yuanbao", "docsLabel": "yuanbao", "blurb": "Tencent Yuanbao AI assistant conversation channel.", "aliases": ["yuanbao", "yb", "tencent-yuanbao", "元宝"], diff --git a/scripts/lib/packaged-extension-dirs.d.mts b/scripts/lib/packaged-extension-dirs.d.mts new file mode 100644 index 000000000000..9d13a2a8301c --- /dev/null +++ b/scripts/lib/packaged-extension-dirs.d.mts @@ -0,0 +1,3 @@ +export function collectExcludedPackagedExtensionDirs(rootPackageJson: { + files?: unknown[]; +}): Set; diff --git a/scripts/lib/packaged-extension-dirs.mjs b/scripts/lib/packaged-extension-dirs.mjs new file mode 100644 index 000000000000..4920c7eb088f --- /dev/null +++ b/scripts/lib/packaged-extension-dirs.mjs @@ -0,0 +1,14 @@ +export function collectExcludedPackagedExtensionDirs(rootPackageJson) { + const excluded = new Set(); + const files = Array.isArray(rootPackageJson?.files) ? rootPackageJson.files : []; + for (const entry of files) { + if (typeof entry !== "string") { + continue; + } + const match = /^!dist\/extensions\/([^/]+)\/\*\*$/u.exec(entry); + if (match?.[1]) { + excluded.add(match[1]); + } + } + return excluded; +} diff --git a/scripts/root-dependency-ownership-audit.mjs b/scripts/root-dependency-ownership-audit.mjs index 5087f08a107a..bdef4ee1a749 100644 --- a/scripts/root-dependency-ownership-audit.mjs +++ b/scripts/root-dependency-ownership-audit.mjs @@ -5,6 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { collectExcludedPackagedExtensionDirs } from "./lib/packaged-extension-dirs.mjs"; import { packageNameFromSpecifier } from "./lib/plugin-package-dependencies.mjs"; const DEFAULT_SCAN_ROOTS = ["src", "extensions", "packages", "ui", "scripts", "test"]; @@ -144,20 +145,6 @@ function collectExtensionDependencyDeclarations(repoRoot) { return declarations; } -function collectExcludedPackagedExtensionDirs(rootPackageJson) { - const excluded = new Set(); - for (const entry of rootPackageJson.files ?? []) { - if (typeof entry !== "string") { - continue; - } - const match = /^!dist\/extensions\/([^/]+)\/\*\*$/u.exec(entry); - if (match?.[1]) { - excluded.add(match[1]); - } - } - return excluded; -} - function collectInternalizedBundledExtensionRuntimeDependencies(repoRoot, rootPackageJson) { const dependencies = new Map(); const extensionsRoot = path.join(repoRoot, "extensions"); diff --git a/scripts/write-official-channel-catalog.d.mts b/scripts/write-official-channel-catalog.d.mts index 6d2133ea5df1..ace6bc3f72ae 100644 --- a/scripts/write-official-channel-catalog.d.mts +++ b/scripts/write-official-channel-catalog.d.mts @@ -1,5 +1,7 @@ export const OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH: "scripts/lib/official-external-channel-catalog.json"; export const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH: "dist/channel-catalog.json"; +export const OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH: "docs/channels/index.md"; +export const OFFICIAL_CHANNEL_DOCS_NAV_RELATIVE_PATH: "docs/docs.json"; export function buildOfficialChannelCatalog(params?: { repoRoot?: string; cwd?: string }): { entries: Array<{ @@ -38,3 +40,38 @@ export function checkOfficialChannelCatalogSource(params?: { repoRoot?: string; cwd?: string; }): boolean; + +export function buildOfficialChannelDocsCatalog(params?: { repoRoot?: string; cwd?: string }): { + entries: Array<{ + id: string; + label: string; + docsPath: string; + summary: string; + source: "official" | "external" | "bundled" | "built-in"; + }>; +}; + +export function renderOfficialChannelDocsIndex(params?: { + repoRoot?: string; + cwd?: string; +}): string; +export function writeOfficialChannelDocsIndex(params?: { + repoRoot?: string; + cwd?: string; +}): boolean; +export function checkOfficialChannelDocsIndex(params?: { + repoRoot?: string; + cwd?: string; +}): boolean; +export function findMissingOfficialChannelDocsNavRoutes(params?: { + repoRoot?: string; + cwd?: string; +}): string[]; +export function findUnexpectedOfficialChannelDocsNavRoutes(params?: { + repoRoot?: string; + cwd?: string; +}): string[]; +export function findDuplicateOfficialChannelDocsNavRoutes(params?: { + repoRoot?: string; + cwd?: string; +}): string[]; diff --git a/scripts/write-official-channel-catalog.mjs b/scripts/write-official-channel-catalog.mjs index 960bec3ba7ea..3244a636b499 100644 --- a/scripts/write-official-channel-catalog.mjs +++ b/scripts/write-official-channel-catalog.mjs @@ -2,13 +2,17 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { parse as parseYaml } from "yaml"; import officialExternalChannelSeed from "./lib/official-external-channel-seed.json" with { type: "json" }; +import { collectExcludedPackagedExtensionDirs } from "./lib/packaged-extension-dirs.mjs"; import { isRecord, trimString } from "./lib/record-shared.mjs"; import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; /** Generated official channel catalog committed for source and packaged runtime consumers. */ export const OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH = "scripts/lib/official-external-channel-catalog.json"; +export const OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH = "docs/channels/index.md"; +export const OFFICIAL_CHANNEL_DOCS_NAV_RELATIVE_PATH = "docs/docs.json"; /** * Generated official channel catalog path in dist. @@ -16,6 +20,55 @@ export const OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH = */ export const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = "dist/channel-catalog.json"; +const OFFICIAL_CHANNEL_DOCS_START_MARKER = ""; +const OFFICIAL_CHANNEL_DOCS_END_MARKER = ""; +const WEBCHAT_DOCS_ENTRY = { + id: "webchat", + docsPath: "/web/webchat", + source: "built-in", +}; + +function readRepositoryPackageJsons(repoRoot) { + const extensionsRoot = path.join(repoRoot, "extensions"); + if (!fs.existsSync(extensionsRoot)) { + return []; + } + + const packageJsons = []; + const extensionDirectories = fs + .readdirSync(extensionsRoot, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .toSorted((left, right) => left.name.localeCompare(right.name)); + for (const dirent of extensionDirectories) { + const packageJsonPath = path.join(extensionsRoot, dirent.name, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + try { + const pluginManifestPath = path.join(extensionsRoot, dirent.name, "openclaw.plugin.json"); + packageJsons.push({ + dirName: dirent.name, + packageJson: JSON.parse(fs.readFileSync(packageJsonPath, "utf8")), + pluginManifest: fs.existsSync(pluginManifestPath) + ? JSON.parse(fs.readFileSync(pluginManifestPath, "utf8")) + : undefined, + }); + } catch { + // Invalid package metadata must not prevent unrelated channels from being generated. + } + } + return packageJsons; +} + +function readExcludedPackagedExtensionDirs(repoRoot) { + const packageJsonPath = path.join(repoRoot, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + return new Set(); + } + const rootPackageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + return collectExcludedPackagedExtensionDirs(rootPackageJson); +} + function toCatalogInstall(value, packageName) { const install = isRecord(value) ? value : {}; const clawhubSpec = trimString(install.clawhubSpec); @@ -114,7 +167,6 @@ function setUniqueCatalogEntry(entriesByChannelId, entry, owner) { */ export function buildOfficialChannelCatalog(params = {}) { const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); - const extensionsRoot = path.join(repoRoot, "extensions"); const seedEntriesByChannelId = new Map(); for (const entry of Array.isArray(officialExternalChannelSeed.entries) ? officialExternalChannelSeed.entries @@ -127,39 +179,14 @@ export function buildOfficialChannelCatalog(params = {}) { } const repositoryEntriesByChannelId = new Map(); - if (fs.existsSync(extensionsRoot)) { - const extensionDirectories = fs - .readdirSync(extensionsRoot, { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()) - .toSorted((left, right) => left.name.localeCompare(right.name)); - for (const dirent of extensionDirectories) { - if (!dirent.isDirectory()) { - continue; - } - const packageJsonPath = path.join(extensionsRoot, dirent.name, "package.json"); - if (!fs.existsSync(packageJsonPath)) { - continue; - } - let packageJson; - let pluginManifest; - try { - packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - const pluginManifestPath = path.join(extensionsRoot, dirent.name, "openclaw.plugin.json"); - pluginManifest = fs.existsSync(pluginManifestPath) - ? JSON.parse(fs.readFileSync(pluginManifestPath, "utf8")) - : undefined; - } catch { - // Ignore invalid package metadata and keep generating the rest of the catalog. - continue; - } - const entry = buildCatalogEntry(packageJson, pluginManifest); - if (entry) { - setUniqueCatalogEntry( - repositoryEntriesByChannelId, - entry, - `extensions/${dirent.name}/package.json`, - ); - } + for (const { dirName, packageJson, pluginManifest } of readRepositoryPackageJsons(repoRoot)) { + const entry = buildCatalogEntry(packageJson, pluginManifest); + if (entry) { + setUniqueCatalogEntry( + repositoryEntriesByChannelId, + entry, + `extensions/${dirName}/package.json`, + ); } } @@ -202,6 +229,340 @@ export function checkOfficialChannelCatalogSource(params = {}) { return current === renderOfficialChannelCatalog({ repoRoot }); } +function toChannelDocsEntry(entry, sourceOverride) { + const channel = isRecord(entry?.openclaw?.channel) ? entry.openclaw.channel : null; + const exposure = channel && isRecord(channel.exposure) ? channel.exposure : null; + if (!channel || exposure?.docs === false) { + return null; + } + const id = trimString(channel.id); + if (!id) { + return null; + } + const docsPath = trimString(channel.docsPath) || `/channels/${id}`; + const source = sourceOverride ?? trimString(entry.source); + return { + id, + docsPath, + source: + source === "external" || source === "bundled" || source === "built-in" ? source : "official", + }; +} + +function readChannelDocsFrontmatter(repoRoot, entry) { + const route = entry.docsPath.split(/[?#]/u, 1)[0]?.replace(/^\/+/u, ""); + if (!route) { + throw new Error(`channel ${entry.id} has an invalid docs route: ${entry.docsPath}`); + } + const candidates = [ + path.join(repoRoot, "docs", `${route}.md`), + path.join(repoRoot, "docs", `${route}.mdx`), + ]; + const docsPath = candidates.find((candidate) => fs.existsSync(candidate)); + if (!docsPath) { + throw new Error(`channel ${entry.id} docs route does not resolve: ${entry.docsPath}`); + } + const content = fs.readFileSync(docsPath, "utf8"); + const frontmatterMatch = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(content); + if (!frontmatterMatch) { + throw new Error(`${path.relative(repoRoot, docsPath)} is missing YAML frontmatter`); + } + const frontmatter = parseYaml(frontmatterMatch[1]); + const label = trimString(frontmatter?.title); + const summary = trimString(frontmatter?.summary); + if (!label || !summary) { + throw new Error(`${path.relative(repoRoot, docsPath)} must define title and summary`); + } + return { ...entry, label, summary }; +} + +function isInstallableChannelManifest(manifest) { + const release = isRecord(manifest.release) ? manifest.release : null; + const install = isRecord(manifest.install) ? manifest.install : null; + return ( + release?.publishToNpm === true || + release?.publishToClawHub === true || + Boolean(trimString(install?.npmSpec) || trimString(install?.clawhubSpec)) + ); +} + +/** + * Builds the public docs projection from the install catalog plus bundled channel manifests. + * @internal Directly tested script implementation detail. + */ +export function buildOfficialChannelDocsCatalog(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const entriesByChannelId = new Map(); + const excludedPackagedExtensionDirs = readExcludedPackagedExtensionDirs(repoRoot); + + for (const entry of buildOfficialChannelCatalog({ repoRoot }).entries) { + const docsEntry = toChannelDocsEntry(entry); + if (docsEntry) { + entriesByChannelId.set(docsEntry.id, docsEntry); + } + } + + for (const { dirName, packageJson } of readRepositoryPackageJsons(repoRoot)) { + const manifest = isRecord(packageJson?.openclaw) ? packageJson.openclaw : null; + const channel = manifest && isRecord(manifest.channel) ? manifest.channel : null; + if (!channel) { + continue; + } + const exposure = isRecord(channel.exposure) ? channel.exposure : null; + const isCoreBundled = !excludedPackagedExtensionDirs.has(dirName); + const isInstallable = isInstallableChannelManifest(manifest); + if (!isCoreBundled && !isInstallable) { + if (exposure?.docs === false) { + continue; + } + throw new Error( + `docs-visible channel ${trimString(channel.id) || dirName} is neither bundled nor installable`, + ); + } + const docsEntry = toChannelDocsEntry( + { openclaw: { channel } }, + isCoreBundled ? "bundled" : "official", + ); + if (docsEntry) { + entriesByChannelId.set(docsEntry.id, docsEntry); + } + } + + entriesByChannelId.set(WEBCHAT_DOCS_ENTRY.id, WEBCHAT_DOCS_ENTRY); + const entries = [...entriesByChannelId.values()].map((entry) => + readChannelDocsFrontmatter(repoRoot, entry), + ); + entries.sort( + (left, right) => + left.label.localeCompare(right.label, "en") || left.id.localeCompare(right.id, "en"), + ); + return { entries }; +} + +function renderChannelDocsSummary(entry) { + const summary = entry.summary.replace(/[.!?]+$/u, ""); + const normalizedSummary = summary + ? `${summary.slice(0, 1).toUpperCase()}${summary.slice(1)}` + : `${entry.label} messaging for OpenClaw`; + const sourceLabel = + entry.source === "external" + ? "external plugin" + : entry.source === "bundled" + ? "bundled plugin" + : entry.source === "built-in" + ? "included in core" + : "official plugin"; + return `- [${entry.label}](${entry.docsPath}) - ${normalizedSummary} (${sourceLabel}).`; +} + +function renderOfficialChannelDocsBlock(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const lines = buildOfficialChannelDocsCatalog({ repoRoot }).entries.map(renderChannelDocsSummary); + return [ + OFFICIAL_CHANNEL_DOCS_START_MARKER, + "", + "", + ...lines, + "", + OFFICIAL_CHANNEL_DOCS_END_MARKER, + ].join("\n"); +} + +function replaceOfficialChannelDocsBlock(current, block) { + const startIndex = current.indexOf(OFFICIAL_CHANNEL_DOCS_START_MARKER); + const endIndex = current.indexOf(OFFICIAL_CHANNEL_DOCS_END_MARKER); + const startCount = current.split(OFFICIAL_CHANNEL_DOCS_START_MARKER).length - 1; + const endCount = current.split(OFFICIAL_CHANNEL_DOCS_END_MARKER).length - 1; + if ( + startCount !== 1 || + endCount !== 1 || + startIndex === -1 || + endIndex === -1 || + endIndex < startIndex + ) { + throw new Error( + `${OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH} must contain exactly one generated channel marker pair`, + ); + } + const afterEndIndex = endIndex + OFFICIAL_CHANNEL_DOCS_END_MARKER.length; + return `${current.slice(0, startIndex)}${block}${current.slice(afterEndIndex)}`; +} + +export function renderOfficialChannelDocsIndex(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH); + const current = fs.readFileSync(outputPath, "utf8"); + return replaceOfficialChannelDocsBlock(current, renderOfficialChannelDocsBlock({ repoRoot })); +} + +export function writeOfficialChannelDocsIndex(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH); + return writeTextFileIfChanged(outputPath, renderOfficialChannelDocsIndex({ repoRoot })); +} + +export function checkOfficialChannelDocsIndex(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH); + if (!fs.existsSync(outputPath)) { + return false; + } + const current = fs.readFileSync(outputPath, "utf8"); + try { + return current === renderOfficialChannelDocsIndex({ repoRoot }); + } catch { + return false; + } +} + +function collectDocsNavPageCounts(node, counts = new Map()) { + if (Array.isArray(node)) { + for (const item of node) { + collectDocsNavPageCounts(item, counts); + } + return counts; + } + if (!isRecord(node)) { + return counts; + } + if (Array.isArray(node.pages)) { + for (const page of node.pages) { + if (typeof page === "string") { + const route = page.replace(/^\/+/u, ""); + counts.set(route, (counts.get(route) ?? 0) + 1); + } else { + collectDocsNavPageCounts(page, counts); + } + } + } + for (const value of Object.values(node)) { + if (value !== node.pages) { + collectDocsNavPageCounts(value, counts); + } + } + return counts; +} + +function findEnglishChannelsTab(docsConfig) { + const navigation = isRecord(docsConfig.navigation) ? docsConfig.navigation : null; + const languages = Array.isArray(navigation?.languages) ? navigation.languages : []; + const english = languages.find( + (language) => isRecord(language) && trimString(language.language) === "en", + ); + const tabs = isRecord(english) && Array.isArray(english.tabs) ? english.tabs : []; + return tabs.find((tab) => isRecord(tab) && trimString(tab.tab) === "Channels") ?? null; +} + +function readDocsNavCounts(repoRoot) { + const navPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_NAV_RELATIVE_PATH); + const docsConfig = JSON.parse(fs.readFileSync(navPath, "utf8")); + return { + all: collectDocsNavPageCounts(docsConfig), + channels: collectDocsNavPageCounts(findEnglishChannelsTab(docsConfig)), + }; +} + +function buildHiddenChannelDocsRoutes(repoRoot) { + const channelsById = new Map(); + for (const entry of Array.isArray(officialExternalChannelSeed.entries) + ? officialExternalChannelSeed.entries + : []) { + const channel = isRecord(entry?.openclaw?.channel) ? entry.openclaw.channel : null; + const channelId = trimString(channel?.id); + if (channelId && channel) { + channelsById.set(channelId, channel); + } + } + for (const { packageJson } of readRepositoryPackageJsons(repoRoot)) { + const manifest = isRecord(packageJson?.openclaw) ? packageJson.openclaw : null; + const channel = manifest && isRecord(manifest.channel) ? manifest.channel : null; + const channelId = trimString(channel?.id); + if (channelId && channel) { + channelsById.set(channelId, channel); + } + } + + const routes = new Set(); + for (const channel of channelsById.values()) { + const exposure = isRecord(channel.exposure) ? channel.exposure : null; + const docsPath = trimString(channel.docsPath); + if (exposure?.docs !== false || !docsPath.startsWith("/")) { + continue; + } + const route = docsPath.split("#", 1)[0]?.replace(/^\/+/u, ""); + if (route) { + routes.add(route); + } + } + return routes; +} + +export function findMissingOfficialChannelDocsNavRoutes(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const navCounts = readDocsNavCounts(repoRoot); + const missing = new Set(); + for (const entry of buildOfficialChannelDocsCatalog({ repoRoot }).entries) { + if (!entry.docsPath.startsWith("/")) { + continue; + } + const route = entry.docsPath.split("#", 1)[0]?.replace(/^\/+/u, ""); + const counts = route?.startsWith("channels/") ? navCounts.channels : navCounts.all; + if (route && (counts.get(route) ?? 0) === 0) { + missing.add(route); + } + } + return [...missing].toSorted((left, right) => left.localeCompare(right, "en")); +} + +export function findUnexpectedOfficialChannelDocsNavRoutes(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const navCounts = readDocsNavCounts(repoRoot).all; + return [...buildHiddenChannelDocsRoutes(repoRoot)] + .filter((route) => (navCounts.get(route) ?? 0) > 0) + .toSorted((left, right) => left.localeCompare(right, "en")); +} + +export function findDuplicateOfficialChannelDocsNavRoutes(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const navCounts = readDocsNavCounts(repoRoot).all; + const duplicateRoutes = new Set(); + for (const entry of buildOfficialChannelDocsCatalog({ repoRoot }).entries) { + const route = entry.docsPath.split("#", 1)[0]?.replace(/^\/+/u, ""); + if (route && (navCounts.get(route) ?? 0) > 1) { + duplicateRoutes.add(route); + } + } + return [...duplicateRoutes].toSorted((left, right) => left.localeCompare(right, "en")); +} + +function reportOfficialChannelDocsNavIssues(params = {}) { + const issueGroups = [ + { + label: "is missing channel routes", + routes: findMissingOfficialChannelDocsNavRoutes(params), + }, + { + label: "exposes hidden channel routes", + routes: findUnexpectedOfficialChannelDocsNavRoutes(params), + }, + { + label: "duplicates channel routes", + routes: findDuplicateOfficialChannelDocsNavRoutes(params), + }, + ]; + let failed = false; + for (const issue of issueGroups) { + if (issue.routes.length === 0) { + continue; + } + console.error( + `${OFFICIAL_CHANNEL_DOCS_NAV_RELATIVE_PATH} ${issue.label}: ${issue.routes.join(", ")}`, + ); + failed = true; + } + return failed; +} + function main(argv = process.argv.slice(2)) { const write = argv.includes("--write"); const check = argv.includes("--check"); @@ -212,12 +573,29 @@ function main(argv = process.argv.slice(2)) { } if (write) { writeOfficialChannelCatalogSource(); + writeOfficialChannelDocsIndex(); + if (reportOfficialChannelDocsNavIssues()) { + process.exitCode = 1; + } return; } + let failed = false; if (!checkOfficialChannelCatalogSource()) { console.error( `${OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH} is stale. Run \`pnpm channels:catalog:gen\`.`, ); + failed = true; + } + if (!checkOfficialChannelDocsIndex()) { + console.error( + `${OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH} is stale. Run \`pnpm channels:catalog:gen\`.`, + ); + failed = true; + } + if (reportOfficialChannelDocsNavIssues()) { + failed = true; + } + if (failed) { process.exitCode = 1; } } diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index 61f76572e654..8c840c837e61 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -927,6 +927,81 @@ describe("server-channels auto restart", () => { expect(lifecycleAtHandoff).toEqual(["starting", "starting"]); }); + it("recovers a manually restarted channel from a transient failure after terminal disconnect", async () => { + const handoffStates: ChannelAccountSnapshot[] = []; + const handoffSignals: AbortSignal[] = []; + const startAccount = vi.fn(async (ctx: ChannelGatewayContext) => { + handoffStates.push({ ...ctx.getStatus() }); + handoffSignals.push(ctx.abortSignal); + if (handoffStates.length === 1) { + ctx.setStatus({ + accountId: ctx.accountId, + terminalDisconnect: true, + lifecycle: "blocked", + lastError: "relink required", + }); + return; + } + if (handoffStates.length === 2) { + throw new Error("transient reconnect failure"); + } + ctx.setStatus({ accountId: ctx.accountId, connected: true }); + await new Promise((resolve) => { + ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }); + }); + installTestRegistry(createTestPlugin({ startAccount })); + const manager = createManager(); + const readAccount = () => + manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]; + + await manager.startChannels(); + await vi.advanceTimersByTimeAsync(20); + + expect(startAccount).toHaveBeenCalledTimes(1); + expect(readAccount()).toMatchObject({ + terminalDisconnect: true, + running: false, + lifecycle: "blocked", + lastError: "relink required", + restartPending: false, + }); + expect(healthOf(readAccount()).reason).toBe("terminal-disconnect"); + expect(hoisted.sleepWithAbort).not.toHaveBeenCalled(); + + await manager.startChannel("discord", DEFAULT_ACCOUNT_ID, { manual: true }); + await advanceTimersUntil( + () => startAccount.mock.calls.length === 3, + "expected a transient failure after manual restart to recover automatically", + { stepMs: 10, maxMs: 100 }, + ); + await flushMicrotasks(); + + expect(handoffStates.map(({ lifecycle }) => lifecycle)).toEqual([ + "starting", + "starting", + "starting", + ]); + expect(handoffStates[1]?.terminalDisconnect).toBeUndefined(); + expect(handoffStates[2]?.terminalDisconnect).toBeUndefined(); + expect(handoffSignals[0]?.aborted).toBe(true); + expect(handoffSignals[1]?.aborted).toBe(true); + expect(handoffSignals[2]?.aborted).toBe(false); + expect(hoisted.sleepWithAbort).toHaveBeenCalledTimes(1); + expect(hoisted.sleepWithAbort.mock.calls[0]?.[0]).toBe(10); + expect(manager.isManuallyStopped("discord", DEFAULT_ACCOUNT_ID)).toBe(false); + expect(readAccount()).toMatchObject({ + connected: true, + running: true, + lifecycle: "ready", + restartPending: false, + lastError: null, + reconnectAttempts: 1, + }); + expect(readAccount()?.terminalDisconnect).toBeUndefined(); + expect(healthOf(readAccount()).reason).not.toBe("terminal-disconnect"); + }); + it("consumes rejected stop tasks during manual abort", async () => { const unhandledRejection = vi.fn(); process.on("unhandledRejection", unhandledRejection); diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index e4114f76fe10..6042a8ef20eb 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -728,10 +728,10 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage restartPending: false, lastStartAt: Date.now(), lastError: null, - // Runtime rows are patch-merged, so a dead-ingress verdict from the - // previous lifecycle would outlive the condition it described. Every - // start re-proves ingress, so every start must clear it first. + // Runtime rows are patch-merged; prior ingress or terminal verdicts + // must not poison a new lifecycle before its plugin reports status. ingressUnavailable: undefined, + terminalDisconnect: undefined, reconnectAttempts: preserveRestartAttempts ? (restarts.get(rKey)?.attempts ?? 0) : 0, }); const task = Promise.resolve().then(async () => { diff --git a/test/official-channel-catalog.test.ts b/test/official-channel-catalog.test.ts index 79f81e0cf2c0..f66634626322 100644 --- a/test/official-channel-catalog.test.ts +++ b/test/official-channel-catalog.test.ts @@ -4,11 +4,19 @@ import path from "node:path"; import { bundledPluginRoot } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it } from "vitest"; import { + buildOfficialChannelDocsCatalog, buildOfficialChannelCatalog, + checkOfficialChannelDocsIndex, checkOfficialChannelCatalogSource, + findDuplicateOfficialChannelDocsNavRoutes, + findMissingOfficialChannelDocsNavRoutes, + findUnexpectedOfficialChannelDocsNavRoutes, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH, OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH, + OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH, + renderOfficialChannelDocsIndex, writeOfficialChannelCatalog, + writeOfficialChannelDocsIndex, writeOfficialChannelCatalogSource, } from "../scripts/write-official-channel-catalog.mjs"; import { describePluginInstallSource } from "../src/plugins/install-source-info.js"; @@ -31,6 +39,66 @@ function writeJson(filePath: string, value: unknown): void { writeJsonFile(filePath, value); } +function writeChannelDocContent(repoRoot: string, docsPath: string, content: string): void { + const route = docsPath.replace(/^\/+/u, ""); + const filePath = path.join(repoRoot, "docs", `${route}.md`); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, "utf8"); +} + +function writeChannelDoc(repoRoot: string, docsPath: string, title: string, summary: string): void { + writeChannelDocContent( + repoRoot, + docsPath, + `---\nsummary: ${JSON.stringify(summary)}\ntitle: ${JSON.stringify(title)}\n---\n`, + ); +} + +function writeExternalChannelDocs(repoRoot: string): void { + const seed = JSON.parse( + fs.readFileSync(path.resolve("scripts/lib/official-external-channel-seed.json"), "utf8"), + ) as { + entries: Array<{ + openclaw?: { channel?: { docsPath?: string; id?: string; label?: string } }; + }>; + }; + for (const entry of seed.entries) { + const channel = entry.openclaw?.channel; + if (!channel?.docsPath || !channel.label) { + continue; + } + const title = channel.id === "openclaw-weixin" ? "WeChat" : channel.label; + writeChannelDoc(repoRoot, channel.docsPath, title, `${title} test summary`); + } + writeChannelDoc(repoRoot, "/web/webchat", "WebChat", "Gateway WebChat UI over WebSocket"); +} + +function writeEnglishDocsNavigation( + repoRoot: string, + channelPages: string[], + otherPages: string[] = [], +): void { + writeJson(path.join(repoRoot, "docs", "docs.json"), { + navigation: { + languages: [ + { + language: "en", + tabs: [ + { + tab: "Channels", + groups: [{ group: "Test channels", pages: channelPages }], + }, + { + tab: "Other", + groups: [{ group: "Other pages", pages: otherPages }], + }, + ], + }, + ], + }, + }); +} + function requireInstall(entry: OfficialChannelCatalogEntry | undefined): OfficialChannelInstall { const install = entry?.openclaw?.install; if (!install) { @@ -81,6 +149,31 @@ describe("buildOfficialChannelCatalog", () => { expect(checkOfficialChannelCatalogSource({ repoRoot: process.cwd() })).toBe(true); }); + it("keeps the generated channel docs index and navigation synchronized", () => { + expect(checkOfficialChannelDocsIndex({ repoRoot: process.cwd() })).toBe(true); + expect(findMissingOfficialChannelDocsNavRoutes({ repoRoot: process.cwd() })).toEqual([]); + expect(findUnexpectedOfficialChannelDocsNavRoutes({ repoRoot: process.cwd() })).toEqual([]); + expect(findDuplicateOfficialChannelDocsNavRoutes({ repoRoot: process.cwd() })).toEqual([]); + + const entries = buildOfficialChannelDocsCatalog({ repoRoot: process.cwd() }).entries; + expect(entries.find((entry) => entry.id === "openclaw-weixin")).toMatchObject({ + label: "WeChat", + summary: "WeChat channel setup through the external openclaw-weixin plugin", + }); + expect(entries.map((entry) => entry.id)).toEqual( + expect.arrayContaining(["reef", "telegram", "webchat"]), + ); + expect(entries.map((entry) => entry.id)).not.toEqual( + expect.arrayContaining(["qa-channel", "voice-call"]), + ); + const rendered = renderOfficialChannelDocsIndex({ repoRoot: process.cwd() }); + expect(rendered).toContain( + "[Voice Call](/plugins/voice-call) - Telephony via Plivo, Telnyx, or Twilio", + ); + expect(rendered).not.toContain("Very well supported right now"); + expect(rendered).not.toContain('David Reagans: "Hop on Discord."'); + }); + it("lets publishable package metadata override same-id seeds and skips non-publishable entries", () => { const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-"); writeJson(path.join(repoRoot, "extensions", "wecom", "package.json"), { @@ -232,7 +325,7 @@ describe("buildOfficialChannelCatalog", () => { selectionLabel: "Yuanbao (元宝)", detailLabel: "Yuanbao", docsLabel: "yuanbao", - docsPath: "/plugins/community#yuanbao", + docsPath: "/channels/yuanbao", blurb: "Tencent Yuanbao AI assistant conversation channel.", order: 85, aliases: ["yuanbao", "yb", "tencent-yuanbao", "元宝"], @@ -342,6 +435,239 @@ describe("buildOfficialChannelCatalog", () => { ).toBe(false); }); + it("projects bundled, external, and built-in channels into docs while hiding source-only channels", () => { + const repoRoot = makeRepoRoot("openclaw-official-channel-docs-"); + writeJson(path.join(repoRoot, "package.json"), { + files: ["dist/extensions/**", "!dist/extensions/hidden/**"], + }); + writeJson(path.join(repoRoot, "extensions", "bundled", "package.json"), { + name: "@openclaw/bundled", + openclaw: { + channel: { + id: "bundled", + label: "Bundled", + docsPath: "/channels/bundled", + blurb: "bundled test channel", + }, + }, + }); + writeJson(path.join(repoRoot, "extensions", "hidden", "package.json"), { + name: "@openclaw/hidden", + openclaw: { + channel: { + id: "hidden", + label: "Hidden", + docsPath: "/channels/hidden", + blurb: "hidden test channel", + exposure: { + docs: false, + }, + }, + }, + }); + writeExternalChannelDocs(repoRoot); + writeChannelDoc(repoRoot, "/channels/bundled", "Bundled Chat", "Public bundled summary"); + + const entries = buildOfficialChannelDocsCatalog({ repoRoot }).entries; + + expect(entries.find((entry) => entry.id === "bundled")).toEqual({ + id: "bundled", + label: "Bundled Chat", + docsPath: "/channels/bundled", + summary: "Public bundled summary", + source: "bundled", + }); + expect(entries.some((entry) => entry.id === "hidden")).toBe(false); + expect(entries.find((entry) => entry.id === "webchat")).toEqual({ + id: "webchat", + label: "WebChat", + docsPath: "/web/webchat", + summary: "Gateway WebChat UI over WebSocket", + source: "built-in", + }); + expect(entries.find((entry) => entry.id === "wecom")?.docsPath).toBe("/channels/wecom"); + expect(entries.find((entry) => entry.id === "yuanbao")?.docsPath).toBe("/channels/yuanbao"); + }); + + it("uses the canonical channel docs route when a manifest omits docsPath", () => { + const repoRoot = makeRepoRoot("openclaw-default-channel-docs-route-"); + writeJson(path.join(repoRoot, "extensions", "defaulted", "package.json"), { + name: "@openclaw/defaulted", + openclaw: { + channel: { + id: "defaulted", + label: "Defaulted", + }, + }, + }); + writeExternalChannelDocs(repoRoot); + writeChannelDoc(repoRoot, "/channels/defaulted", "Defaulted Chat", "Default route summary"); + + expect( + buildOfficialChannelDocsCatalog({ repoRoot }).entries.find( + (entry) => entry.id === "defaulted", + ), + ).toEqual({ + id: "defaulted", + label: "Defaulted Chat", + docsPath: "/channels/defaulted", + summary: "Default route summary", + source: "bundled", + }); + }); + + it("rejects docs-visible source-only channels", () => { + const repoRoot = makeRepoRoot("openclaw-source-only-channel-docs-"); + writeJson(path.join(repoRoot, "package.json"), { + files: ["dist/extensions/**", "!dist/extensions/source-only/**"], + }); + writeJson(path.join(repoRoot, "extensions", "source-only", "package.json"), { + name: "@openclaw/source-only", + openclaw: { + channel: { + id: "source-only", + label: "Source Only", + docsPath: "/channels/source-only", + }, + }, + }); + + expect(() => buildOfficialChannelDocsCatalog({ repoRoot })).toThrow( + "docs-visible channel source-only is neither bundled nor installable", + ); + }); + + it.each([ + { + name: "missing docs file", + content: null, + error: "channel frontmatter-test docs route does not resolve", + }, + { + name: "missing frontmatter", + content: "# Frontmatter test\n", + error: "docs/channels/frontmatter-test.md is missing YAML frontmatter", + }, + { + name: "missing title", + content: '---\nsummary: "Summary"\n---\n', + error: "docs/channels/frontmatter-test.md must define title and summary", + }, + { + name: "missing summary", + content: '---\ntitle: "Frontmatter test"\n---\n', + error: "docs/channels/frontmatter-test.md must define title and summary", + }, + ])("rejects channel docs with $name", ({ content, error }) => { + const repoRoot = makeRepoRoot("openclaw-channel-docs-frontmatter-"); + writeJson(path.join(repoRoot, "extensions", "frontmatter-test", "package.json"), { + name: "@openclaw/frontmatter-test", + openclaw: { + channel: { + id: "frontmatter-test", + label: "Manifest label", + docsPath: "/channels/frontmatter-test", + blurb: "Manifest blurb", + }, + }, + }); + writeExternalChannelDocs(repoRoot); + if (content !== null) { + writeChannelDocContent(repoRoot, "/channels/frontmatter-test", content); + } + + expect(() => buildOfficialChannelDocsCatalog({ repoRoot })).toThrow(error); + }); + + it("writes the generated docs block and reports missing or hidden navigation routes", () => { + const repoRoot = makeRepoRoot("openclaw-official-channel-docs-write-"); + writeJson(path.join(repoRoot, "extensions", "bundled", "package.json"), { + name: "@openclaw/bundled", + openclaw: { + channel: { + id: "bundled", + label: "Bundled", + docsPath: "/channels/bundled", + blurb: "bundled test channel", + }, + }, + }); + writeJson(path.join(repoRoot, "extensions", "hidden", "package.json"), { + name: "@openclaw/hidden", + openclaw: { + channel: { + id: "hidden", + label: "Hidden", + docsPath: "/channels/hidden", + exposure: { + docs: false, + }, + }, + }, + }); + writeExternalChannelDocs(repoRoot); + writeChannelDoc(repoRoot, "/channels/bundled", "Bundled Chat", "Public bundled summary"); + const docsIndexPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH); + fs.mkdirSync(path.dirname(docsIndexPath), { recursive: true }); + fs.writeFileSync( + docsIndexPath, + [ + "# Channels", + "", + "", + "- stale", + "", + "", + "Footer", + "", + ].join("\n"), + "utf8", + ); + writeEnglishDocsNavigation(repoRoot, ["channels/hidden"], ["web/webchat", "channels/bundled"]); + + expect(checkOfficialChannelDocsIndex({ repoRoot })).toBe(false); + expect(renderOfficialChannelDocsIndex({ repoRoot })).toContain( + "- [Bundled Chat](/channels/bundled) - Public bundled summary (bundled plugin).", + ); + expect(writeOfficialChannelDocsIndex({ repoRoot })).toBe(true); + expect(writeOfficialChannelDocsIndex({ repoRoot })).toBe(false); + expect(checkOfficialChannelDocsIndex({ repoRoot })).toBe(true); + const generatedIndex = fs.readFileSync(docsIndexPath, "utf8"); + expect(generatedIndex).toMatch(/^# Channels\n/u); + expect(generatedIndex).toContain("\nFooter\n"); + expect(findMissingOfficialChannelDocsNavRoutes({ repoRoot })).toContain("channels/bundled"); + expect(findUnexpectedOfficialChannelDocsNavRoutes({ repoRoot })).toEqual(["channels/hidden"]); + expect(findDuplicateOfficialChannelDocsNavRoutes({ repoRoot })).toEqual([]); + + writeEnglishDocsNavigation(repoRoot, ["channels/bundled", "channels/bundled"], ["web/webchat"]); + expect(findDuplicateOfficialChannelDocsNavRoutes({ repoRoot })).toEqual(["channels/bundled"]); + }); + + it("rejects missing or duplicate generated docs markers", () => { + const repoRoot = makeRepoRoot("openclaw-official-channel-docs-markers-"); + writeExternalChannelDocs(repoRoot); + const docsIndexPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH); + fs.mkdirSync(path.dirname(docsIndexPath), { recursive: true }); + fs.writeFileSync(docsIndexPath, "# Channels\n", "utf8"); + + expect(() => renderOfficialChannelDocsIndex({ repoRoot })).toThrow( + "must contain exactly one generated channel marker pair", + ); + + fs.writeFileSync( + docsIndexPath, + [ + "", + "", + "", + ].join("\n"), + "utf8", + ); + expect(() => renderOfficialChannelDocsIndex({ repoRoot })).toThrow( + "must contain exactly one generated channel marker pair", + ); + }); + it("keeps third-party official external catalog npm sources exactly pinned", () => { const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-policy-"); const entries = buildOfficialChannelCatalog({ repoRoot }).entries.filter(