Merge origin/main into fix/daytona-hydrate-cache-fallback

* origin/main:
  fix(channels): generate discovery docs from catalog (#118106)
  fix(gateway): clear stale terminal state when channel recovery starts (#118728)
This commit is contained in:
Vincent Koc
2026-08-03 08:55:50 -07:00
17 changed files with 969 additions and 106 deletions

View File

@@ -387,6 +387,10 @@
"source": "WeChat",
"target": "微信"
},
{
"source": "WeCom",
"target": "企业微信"
},
{
"source": "Weixin",
"target": "微信"

View File

@@ -135,6 +135,10 @@
"source": "WeChat",
"target": "微信"
},
{
"source": "WeCom",
"target": "企業微信"
},
{
"source": "Weixin",
"target": "微信"

View File

@@ -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/<id>`)
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/<id>`) 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).
<!-- BEGIN GENERATED: official channel catalog -->
<!-- Generated by `pnpm channels:catalog:gen`. Edit manifests/seed for membership and routes; edit page frontmatter for public names and summaries. -->
- [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).
<!-- END GENERATED: official channel catalog -->
### 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

34
docs/channels/wecom.md Normal file
View File

@@ -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.

View File

@@ -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"
]
}
]

View File

@@ -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"

View File

@@ -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;

View File

@@ -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": [

View File

@@ -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", "元宝"],

View File

@@ -0,0 +1,3 @@
export function collectExcludedPackagedExtensionDirs(rootPackageJson: {
files?: unknown[];
}): Set<string>;

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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[];

View File

@@ -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 = "<!-- BEGIN GENERATED: official channel catalog -->";
const OFFICIAL_CHANNEL_DOCS_END_MARKER = "<!-- END GENERATED: official channel catalog -->";
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,
"<!-- Generated by `pnpm channels:catalog:gen`. Edit manifests/seed for membership and routes; edit page frontmatter for public names and summaries. -->",
"",
...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;
}
}

View File

@@ -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<TestAccount>) => {
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<void>((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);

View File

@@ -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 () => {

View File

@@ -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",
"",
"<!-- BEGIN GENERATED: official channel catalog -->",
"- stale",
"<!-- END GENERATED: official channel catalog -->",
"",
"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,
[
"<!-- BEGIN GENERATED: official channel catalog -->",
"<!-- BEGIN GENERATED: official channel catalog -->",
"<!-- END GENERATED: official channel catalog -->",
].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(