refactor: derive channel metadata from plugin manifests

This commit is contained in:
Peter Steinberger 2026-03-28 17:16:45 +00:00
parent c14b169a1b
commit 02d4c1f2c3
19 changed files with 212 additions and 138 deletions

View File

@ -34,7 +34,8 @@
"docsPath": "/channels/discord",
"docsLabel": "discord",
"blurb": "very well supported right now.",
"systemImage": "bubble.left.and.bubble.right"
"systemImage": "bubble.left.and.bubble.right",
"markdownCapable": true
},
"install": {
"npmSpec": "@openclaw/discord",

View File

@ -30,12 +30,14 @@
"detailLabel": "Google Chat",
"docsPath": "/channels/googlechat",
"docsLabel": "googlechat",
"blurb": "Google Workspace Chat app via HTTP webhooks.",
"blurb": "Google Workspace Chat app with HTTP webhook.",
"aliases": [
"gchat",
"google-chat"
],
"order": 55
"order": 55,
"systemImage": "message.badge",
"markdownCapable": true
},
"install": {
"npmSpec": "@openclaw/googlechat",

View File

@ -19,6 +19,9 @@
"docsPath": "/channels/irc",
"docsLabel": "irc",
"blurb": "classic IRC networks with DM/channel routing and pairing controls.",
"aliases": [
"internet-relay-chat"
],
"systemImage": "network"
}
}

View File

@ -24,9 +24,11 @@
"id": "line",
"label": "LINE",
"selectionLabel": "LINE (Messaging API)",
"detailLabel": "LINE Bot",
"docsPath": "/channels/line",
"docsLabel": "line",
"blurb": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
"blurb": "LINE Messaging API webhook bot.",
"systemImage": "message",
"order": 75,
"quickstartAllowFrom": true
},

View File

@ -17,7 +17,8 @@
"docsPath": "/channels/signal",
"docsLabel": "signal",
"blurb": "signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").",
"systemImage": "antenna.radiowaves.left.and.right"
"systemImage": "antenna.radiowaves.left.and.right",
"markdownCapable": true
}
}
}

View File

@ -21,7 +21,8 @@
"docsPath": "/channels/slack",
"docsLabel": "slack",
"blurb": "supported (Socket Mode).",
"systemImage": "number"
"systemImage": "number",
"markdownCapable": true
},
"bundle": {
"stageRuntimeDependencies": true

View File

@ -22,7 +22,13 @@
"docsPath": "/channels/telegram",
"docsLabel": "telegram",
"blurb": "simplest way to get started — register a bot with @BotFather and get going.",
"systemImage": "paperplane"
"systemImage": "paperplane",
"selectionDocsPrefix": "",
"selectionDocsOmitLabel": true,
"selectionExtras": [
"https://openclaw.ai"
],
"markdownCapable": true
},
"bundle": {
"stageRuntimeDependencies": true

View File

@ -1,112 +1,111 @@
import { GENERATED_BUNDLED_PLUGIN_METADATA } from "../plugins/bundled-plugin-metadata.generated.js";
import type { PluginPackageChannel } from "../plugins/manifest.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "./ids.js";
import type { ChannelMeta } from "./plugins/types.js";
export type ChatChannelMeta = ChannelMeta;
const WEBSITE_URL = "https://openclaw.ai";
const CHAT_CHANNEL_ID_SET = new Set<string>(CHAT_CHANNEL_ORDER);
const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
telegram: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram (Bot API)",
detailLabel: "Telegram Bot",
docsPath: "/channels/telegram",
docsLabel: "telegram",
blurb: "simplest way to get started — register a bot with @BotFather and get going.",
systemImage: "paperplane",
selectionDocsPrefix: "",
selectionDocsOmitLabel: true,
selectionExtras: [WEBSITE_URL],
},
whatsapp: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp (QR link)",
detailLabel: "WhatsApp Web",
docsPath: "/channels/whatsapp",
docsLabel: "whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
systemImage: "message",
},
discord: {
id: "discord",
label: "Discord",
selectionLabel: "Discord (Bot API)",
detailLabel: "Discord Bot",
docsPath: "/channels/discord",
docsLabel: "discord",
blurb: "very well supported right now.",
systemImage: "bubble.left.and.bubble.right",
},
irc: {
id: "irc",
label: "IRC",
selectionLabel: "IRC (Server + Nick)",
detailLabel: "IRC",
docsPath: "/channels/irc",
docsLabel: "irc",
blurb: "classic IRC networks with DM/channel routing and pairing controls.",
systemImage: "network",
},
googlechat: {
id: "googlechat",
label: "Google Chat",
selectionLabel: "Google Chat (Chat API)",
detailLabel: "Google Chat",
docsPath: "/channels/googlechat",
docsLabel: "googlechat",
blurb: "Google Workspace Chat app with HTTP webhook.",
systemImage: "message.badge",
},
slack: {
id: "slack",
label: "Slack",
selectionLabel: "Slack (Socket Mode)",
detailLabel: "Slack Bot",
docsPath: "/channels/slack",
docsLabel: "slack",
blurb: "supported (Socket Mode).",
systemImage: "number",
},
signal: {
id: "signal",
label: "Signal",
selectionLabel: "Signal (signal-cli)",
detailLabel: "Signal REST",
docsPath: "/channels/signal",
docsLabel: "signal",
blurb: 'signal-cli linked device; more setup (David Reagans: "Hop on Discord.").',
systemImage: "antenna.radiowaves.left.and.right",
},
imessage: {
id: "imessage",
label: "iMessage",
selectionLabel: "iMessage (imsg)",
detailLabel: "iMessage",
docsPath: "/channels/imessage",
docsLabel: "imessage",
blurb: "this is still a work in progress.",
systemImage: "message.fill",
},
line: {
id: "line",
label: "LINE",
selectionLabel: "LINE (Messaging API)",
detailLabel: "LINE Bot",
docsPath: "/channels/line",
docsLabel: "line",
blurb: "LINE Messaging API webhook bot.",
systemImage: "message",
},
};
function toChatChannelMeta(params: {
id: ChatChannelId;
channel: PluginPackageChannel;
}): ChatChannelMeta {
const label = params.channel.label?.trim();
if (!label) {
throw new Error(`Missing label for bundled chat channel "${params.id}"`);
}
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
imsg: "imessage",
"internet-relay-chat": "irc",
"google-chat": "googlechat",
gchat: "googlechat",
};
return {
id: params.id,
label,
selectionLabel: params.channel.selectionLabel?.trim() || label,
docsPath: params.channel.docsPath?.trim() || `/channels/${params.id}`,
docsLabel: params.channel.docsLabel?.trim() || undefined,
blurb: params.channel.blurb?.trim() || "",
...(params.channel.aliases?.length ? { aliases: params.channel.aliases } : {}),
...(params.channel.order !== undefined ? { order: params.channel.order } : {}),
...(params.channel.selectionDocsPrefix !== undefined
? { selectionDocsPrefix: params.channel.selectionDocsPrefix }
: {}),
...(params.channel.selectionDocsOmitLabel !== undefined
? { selectionDocsOmitLabel: params.channel.selectionDocsOmitLabel }
: {}),
...(params.channel.selectionExtras?.length
? { selectionExtras: params.channel.selectionExtras }
: {}),
...(params.channel.detailLabel?.trim()
? { detailLabel: params.channel.detailLabel.trim() }
: {}),
...(params.channel.systemImage?.trim()
? { systemImage: params.channel.systemImage.trim() }
: {}),
...(params.channel.markdownCapable !== undefined
? { markdownCapable: params.channel.markdownCapable }
: {}),
...(params.channel.showConfigured !== undefined
? { showConfigured: params.channel.showConfigured }
: {}),
...(params.channel.quickstartAllowFrom !== undefined
? { quickstartAllowFrom: params.channel.quickstartAllowFrom }
: {}),
...(params.channel.forceAccountBinding !== undefined
? { forceAccountBinding: params.channel.forceAccountBinding }
: {}),
...(params.channel.preferSessionLookupForAnnounceTarget !== undefined
? {
preferSessionLookupForAnnounceTarget: params.channel.preferSessionLookupForAnnounceTarget,
}
: {}),
...(params.channel.preferOver?.length ? { preferOver: params.channel.preferOver } : {}),
};
}
function buildChatChannelMetaById(): Record<ChatChannelId, ChatChannelMeta> {
const entries = new Map<ChatChannelId, ChatChannelMeta>();
for (const entry of GENERATED_BUNDLED_PLUGIN_METADATA) {
const channel =
entry.packageManifest && "channel" in entry.packageManifest
? entry.packageManifest.channel
: undefined;
if (!channel) {
continue;
}
const rawId = channel?.id?.trim();
if (!rawId || !CHAT_CHANNEL_ID_SET.has(rawId)) {
continue;
}
const id = rawId as ChatChannelId;
entries.set(
id,
toChatChannelMeta({
id,
channel,
}),
);
}
const missingIds = CHAT_CHANNEL_ORDER.filter((id) => !entries.has(id));
if (missingIds.length > 0) {
throw new Error(`Missing bundled chat channel metadata for: ${missingIds.join(", ")}`);
}
return Object.freeze(Object.fromEntries(entries)) as Record<ChatChannelId, ChatChannelMeta>;
}
const CHAT_CHANNEL_META = buildChatChannelMetaById();
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = Object.freeze(
Object.fromEntries(
Object.values(CHAT_CHANNEL_META)
.flatMap((meta) =>
(meta.aliases ?? []).map((alias) => [alias.trim().toLowerCase(), meta.id] as const),
)
.filter(([alias]) => alias.length > 0)
.toSorted(([left], [right]) => left.localeCompare(right)),
),
) as Record<string, ChatChannelId>;
function normalizeChannelKey(raw?: string | null): string | undefined {
const normalized = raw?.trim().toLowerCase();

View File

@ -200,6 +200,9 @@ function toChannelMeta(params: {
: {}),
...(params.channel.selectionExtras ? { selectionExtras: params.channel.selectionExtras } : {}),
...(systemImage ? { systemImage } : {}),
...(params.channel.markdownCapable !== undefined
? { markdownCapable: params.channel.markdownCapable }
: {}),
...(params.channel.showConfigured !== undefined
? { showConfigured: params.channel.showConfigured }
: {}),

View File

@ -127,17 +127,18 @@ export type ChannelMeta = {
docsLabel?: string;
blurb: string;
order?: number;
aliases?: string[];
aliases?: readonly string[];
selectionDocsPrefix?: string;
selectionDocsOmitLabel?: boolean;
selectionExtras?: string[];
selectionExtras?: readonly string[];
detailLabel?: string;
systemImage?: string;
markdownCapable?: boolean;
showConfigured?: boolean;
quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean;
preferSessionLookupForAnnounceTarget?: boolean;
preferOver?: string[];
preferOver?: readonly string[];
};
/** Snapshot row returned by channel status and lifecycle surfaces. */

View File

@ -8,14 +8,14 @@ import {
type ChatChannelMeta,
} from "./chat-meta.js";
import { CHANNEL_IDS, CHAT_CHANNEL_ORDER, type ChatChannelId } from "./ids.js";
import type { ChannelId } from "./plugins/types.js";
import type { ChannelId, ChannelMeta } from "./plugins/types.js";
export { CHANNEL_IDS, CHAT_CHANNEL_ORDER } from "./ids.js";
export type { ChatChannelId } from "./ids.js";
type RegisteredChannelPluginEntry = {
plugin: {
id?: string | null;
meta?: { aliases?: string[] | null } | null;
meta?: Pick<ChannelMeta, "aliases" | "markdownCapable"> | null;
};
};
@ -39,6 +39,18 @@ function findRegisteredChannelPluginEntry(
});
}
function findRegisteredChannelPluginEntryById(
id: string,
): RegisteredChannelPluginEntry | undefined {
const normalizedId = normalizeChannelKey(id);
if (!normalizedId) {
return undefined;
}
return listRegisteredChannelPluginEntries().find(
(entry) => normalizeChannelKey(entry.plugin.id) === normalizedId,
);
}
const normalizeChannelKey = (raw?: string | null): string | undefined => {
const normalized = raw?.trim().toLowerCase();
return normalized || undefined;
@ -80,6 +92,12 @@ export function listRegisteredChannelPluginAliases(): string[] {
return listRegisteredChannelPluginEntries().flatMap((entry) => entry.plugin.meta?.aliases ?? []);
}
export function getRegisteredChannelPluginMeta(
id: string,
): Pick<ChannelMeta, "aliases" | "markdownCapable"> | null {
return findRegisteredChannelPluginEntryById(id)?.plugin.meta ?? null;
}
export function formatChannelPrimerLine(meta: ChatChannelMeta): string {
return `${meta.label}: ${meta.blurb}`;
}

View File

@ -349,16 +349,16 @@ function resolvePreferredOverIds(
): string[] {
const normalized = normalizeChatChannelId(pluginId);
if (normalized) {
return getChatChannelMeta(normalized).preferOver ?? [];
return [...(getChatChannelMeta(normalized).preferOver ?? [])];
}
const installedPlugin = registry.plugins.find((record) => record.id === pluginId);
const manifestChannelPreferOver = installedPlugin?.channelConfigs?.[pluginId]?.preferOver;
if (manifestChannelPreferOver?.length) {
return manifestChannelPreferOver;
return [...manifestChannelPreferOver];
}
const installedChannelMeta = installedPlugin?.channelCatalogMeta;
if (installedChannelMeta?.preferOver?.length) {
return installedChannelMeta.preferOver;
return [...installedChannelMeta.preferOver];
}
return resolveExternalCatalogPreferOver(pluginId, env);
}

View File

@ -125,7 +125,7 @@ function resolveChannelSupportsCurrentConversationBinding(channel: string): bool
if (!normalized) {
return false;
}
const matchesPluginId = (plugin: { id: string; meta?: { aliases?: string[] } }) =>
const matchesPluginId = (plugin: { id: string; meta?: { aliases?: readonly string[] } }) =>
plugin.id === normalized ||
(plugin.meta?.aliases ?? []).some((alias) => alias.trim().toLowerCase() === normalized);
const plugin =

View File

@ -1369,6 +1369,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
docsLabel: "discord",
blurb: "very well supported right now.",
systemImage: "bubble.left.and.bubble.right",
markdownCapable: true,
},
install: {
npmSpec: "@openclaw/discord",
@ -5574,9 +5575,11 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
detailLabel: "Google Chat",
docsPath: "/channels/googlechat",
docsLabel: "googlechat",
blurb: "Google Workspace Chat app via HTTP webhooks.",
blurb: "Google Workspace Chat app with HTTP webhook.",
aliases: ["gchat", "google-chat"],
order: 55,
systemImage: "message.badge",
markdownCapable: true,
},
install: {
npmSpec: "@openclaw/googlechat",
@ -6368,7 +6371,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
additionalProperties: false,
},
label: "Google Chat",
description: "Google Workspace Chat app via HTTP webhooks.",
description: "Google Workspace Chat app with HTTP webhook.",
},
},
},
@ -7094,6 +7097,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
docsPath: "/channels/irc",
docsLabel: "irc",
blurb: "classic IRC networks with DM/channel routing and pairing controls.",
aliases: ["internet-relay-chat"],
systemImage: "network",
},
install: {
@ -7851,9 +7855,11 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
id: "line",
label: "LINE",
selectionLabel: "LINE (Messaging API)",
detailLabel: "LINE Bot",
docsPath: "/channels/line",
docsLabel: "line",
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
blurb: "LINE Messaging API webhook bot.",
systemImage: "message",
order: 75,
quickstartAllowFrom: true,
},
@ -8105,7 +8111,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
additionalProperties: false,
},
label: "LINE",
description: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
description: "LINE Messaging API webhook bot.",
},
},
},
@ -11938,6 +11944,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
docsLabel: "signal",
blurb: 'signal-cli linked device; more setup (David Reagans: "Hop on Discord.").',
systemImage: "antenna.radiowaves.left.and.right",
markdownCapable: true,
},
},
manifest: {
@ -12634,6 +12641,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
docsLabel: "slack",
blurb: "supported (Socket Mode).",
systemImage: "number",
markdownCapable: true,
},
},
manifest: {
@ -14549,6 +14557,10 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
docsLabel: "telegram",
blurb: "simplest way to get started — register a bot with @BotFather and get going.",
systemImage: "paperplane",
selectionDocsPrefix: "",
selectionDocsOmitLabel: true,
selectionExtras: ["https://openclaw.ai"],
markdownCapable: true,
},
},
manifest: {

View File

@ -74,7 +74,7 @@ export type PluginManifestRecord = {
id: string;
label?: string;
blurb?: string;
preferOver?: string[];
preferOver?: readonly string[];
};
};

View File

@ -343,12 +343,13 @@ export type PluginPackageChannel = {
docsLabel?: string;
blurb?: string;
order?: number;
aliases?: string[];
preferOver?: string[];
aliases?: readonly string[];
preferOver?: readonly string[];
systemImage?: string;
selectionDocsPrefix?: string;
selectionDocsOmitLabel?: boolean;
selectionExtras?: string[];
selectionExtras?: readonly string[];
markdownCapable?: boolean;
showConfigured?: boolean;
quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean;

View File

@ -43,6 +43,7 @@ export const createChannelTestPluginBase = (params: {
id: ChannelId;
label?: string;
docsPath?: string;
markdownCapable?: boolean;
capabilities?: ChannelCapabilities;
config?: Partial<ChannelPlugin["config"]>;
}): Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config"> => ({
@ -53,6 +54,7 @@ export const createChannelTestPluginBase = (params: {
selectionLabel: params.label ?? String(params.id),
docsPath: params.docsPath ?? `/channels/${params.id}`,
blurb: "test stub.",
...(params.markdownCapable !== undefined ? { markdownCapable: params.markdownCapable } : {}),
},
capabilities: params.capabilities ?? { chatTypes: ["direct"] },
config: {

View File

@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import { resolveGatewayMessageChannel } from "./message-channel.js";
import {
isMarkdownCapableMessageChannel,
resolveGatewayMessageChannel,
} from "./message-channel.js";
const emptyRegistry = createTestRegistry([]);
const demoAliasPlugin: ChannelPlugin = {
@ -21,6 +24,15 @@ const demoAliasPlugin: ChannelPlugin = {
},
};
const demoMarkdownPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "demo-markdown-channel",
label: "Demo Markdown Channel",
docsPath: "/channels/demo-markdown-channel",
markdownCapable: true,
}),
};
describe("message-channel", () => {
beforeEach(() => {
setActivePluginRegistry(emptyRegistry);
@ -45,4 +57,15 @@ describe("message-channel", () => {
);
expect(resolveGatewayMessageChannel("workspace-chat")).toBe("demo-alias-channel");
});
it("reads markdown capability from channel metadata", () => {
expect(isMarkdownCapableMessageChannel("telegram")).toBe(true);
expect(isMarkdownCapableMessageChannel("whatsapp")).toBe(false);
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "demo-markdown-channel", plugin: demoMarkdownPlugin, source: "test" },
]),
);
expect(isMarkdownCapableMessageChannel("demo-markdown-channel")).toBe(true);
});
});

View File

@ -1,6 +1,8 @@
import type { ChannelId } from "../channels/plugins/types.js";
import {
CHANNEL_IDS,
getChatChannelMeta,
getRegisteredChannelPluginMeta,
listRegisteredChannelPluginAliases,
listRegisteredChannelPluginIds,
listChatChannelAliases,
@ -19,16 +21,6 @@ import {
export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const;
export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL;
const MARKDOWN_CAPABLE_CHANNELS = new Set<string>([
"slack",
"telegram",
"signal",
"discord",
"googlechat",
"tui",
INTERNAL_MESSAGE_CHANNEL,
]);
export { GATEWAY_CLIENT_NAMES, GATEWAY_CLIENT_MODES };
export type { GatewayClientName, GatewayClientMode };
export { normalizeGatewayClientName, normalizeGatewayClientMode };
@ -139,5 +131,12 @@ export function isMarkdownCapableMessageChannel(raw?: string | null): boolean {
if (!channel) {
return false;
}
return MARKDOWN_CAPABLE_CHANNELS.has(channel);
if (channel === INTERNAL_MESSAGE_CHANNEL || channel === "tui") {
return true;
}
const builtInChannel = normalizeChatChannelId(channel);
if (builtInChannel) {
return getChatChannelMeta(builtInChannel).markdownCapable === true;
}
return getRegisteredChannelPluginMeta(channel)?.markdownCapable === true;
}