mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 10:34:44 +00:00
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:
@@ -387,6 +387,10 @@
|
||||
"source": "WeChat",
|
||||
"target": "微信"
|
||||
},
|
||||
{
|
||||
"source": "WeCom",
|
||||
"target": "企业微信"
|
||||
},
|
||||
{
|
||||
"source": "Weixin",
|
||||
"target": "微信"
|
||||
|
||||
@@ -135,6 +135,10 @@
|
||||
"source": "WeChat",
|
||||
"target": "微信"
|
||||
},
|
||||
{
|
||||
"source": "WeCom",
|
||||
"target": "企業微信"
|
||||
},
|
||||
{
|
||||
"source": "Weixin",
|
||||
"target": "微信"
|
||||
|
||||
@@ -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
34
docs/channels/wecom.md
Normal 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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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", "元宝"],
|
||||
|
||||
3
scripts/lib/packaged-extension-dirs.d.mts
Normal file
3
scripts/lib/packaged-extension-dirs.d.mts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function collectExcludedPackagedExtensionDirs(rootPackageJson: {
|
||||
files?: unknown[];
|
||||
}): Set<string>;
|
||||
14
scripts/lib/packaged-extension-dirs.mjs
Normal file
14
scripts/lib/packaged-extension-dirs.mjs
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user