mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
fix(onboard): normalize channel setup metadata (#66706)
thanks @darkamenosa
This commit is contained in:
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/OpenAI Responses: classify the exact `Unknown error (no error details in response)` transport failure as failover reason `unknown` so assistant/model fallback still runs for that no-details failure path. (#65254) Thanks @OpenCodeEngineer.
|
||||
- Models/probe: surface invalid-model probe failures as `format` instead of `unknown` in `models list --probe`, and lock the invalid-model fallback path in with regression coverage. (#50028) Thanks @xiwuqi.
|
||||
- Agents/failover: classify OpenAI-compatible `finish_reason: network_error` stream failures as timeout so model fallback retries continue instead of stopping with an unknown failover reason. (#61784) thanks @lawrence3699.
|
||||
- Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa.
|
||||
|
||||
## 2026.4.14
|
||||
|
||||
|
||||
49
src/channels/plugins/meta-normalization.ts
Normal file
49
src/channels/plugins/meta-normalization.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import type { ChannelMeta } from "./types.public.js";
|
||||
|
||||
function stripRequiredChannelMeta(meta?: Partial<ChannelMeta> | null) {
|
||||
const {
|
||||
id: _ignoredId,
|
||||
label: _ignoredLabel,
|
||||
selectionLabel: _ignoredSelectionLabel,
|
||||
docsPath: _ignoredDocsPath,
|
||||
blurb: _ignoredBlurb,
|
||||
...rest
|
||||
} = meta ?? {};
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function normalizeChannelMeta<TId extends string>(params: {
|
||||
id: TId;
|
||||
meta?: Partial<ChannelMeta> | null;
|
||||
existing?: Partial<ChannelMeta> | null;
|
||||
}): ChannelMeta & { id: TId } {
|
||||
const next = params.meta ?? undefined;
|
||||
const existing = params.existing ?? undefined;
|
||||
const label =
|
||||
normalizeOptionalString(next?.label) ??
|
||||
normalizeOptionalString(existing?.label) ??
|
||||
normalizeOptionalString(next?.selectionLabel) ??
|
||||
normalizeOptionalString(existing?.selectionLabel) ??
|
||||
params.id;
|
||||
const selectionLabel =
|
||||
normalizeOptionalString(next?.selectionLabel) ??
|
||||
normalizeOptionalString(existing?.selectionLabel) ??
|
||||
label;
|
||||
const docsPath =
|
||||
normalizeOptionalString(next?.docsPath) ??
|
||||
normalizeOptionalString(existing?.docsPath) ??
|
||||
`/channels/${params.id}`;
|
||||
const blurb =
|
||||
normalizeOptionalString(next?.blurb) ?? normalizeOptionalString(existing?.blurb) ?? "";
|
||||
|
||||
return {
|
||||
...stripRequiredChannelMeta(existing),
|
||||
...stripRequiredChannelMeta(next),
|
||||
id: params.id,
|
||||
label,
|
||||
selectionLabel,
|
||||
docsPath,
|
||||
blurb,
|
||||
} as ChannelMeta & { id: TId };
|
||||
}
|
||||
@@ -116,4 +116,42 @@ describe("listManifestInstalledChannelIds", () => {
|
||||
|
||||
expect(resolved.entries.map((entry) => entry.id)).toEqual(["telegram"]);
|
||||
});
|
||||
|
||||
it("preserves bundled channel display metadata when installed setup plugins omit it", () => {
|
||||
listChatChannels.mockReturnValue([
|
||||
{
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "bot token",
|
||||
},
|
||||
]);
|
||||
|
||||
const resolved = resolveChannelSetupEntries({
|
||||
cfg: {} as never,
|
||||
installedPlugins: [
|
||||
{
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
},
|
||||
} as never,
|
||||
],
|
||||
workspaceDir: "/tmp/workspace",
|
||||
env: { OPENCLAW_HOME: "/tmp/home" } as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(resolved.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "telegram",
|
||||
meta: expect.objectContaining({
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
blurb: "bot token",
|
||||
docsPath: "/channels/telegram",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag
|
||||
import { listChatChannels } from "../../channels/chat-meta.js";
|
||||
import { type ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
|
||||
import { isChannelVisibleInSetup } from "../../channels/plugins/exposure.js";
|
||||
import { normalizeChannelMeta } from "../../channels/plugins/meta-normalization.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
import type { ChannelMeta } from "../../channels/plugins/types.public.js";
|
||||
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
|
||||
@@ -89,34 +90,77 @@ export function resolveChannelSetupEntries(params: {
|
||||
workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
const installedCatalogEntries = installedCatalogEntriesSource.filter(
|
||||
(entry) =>
|
||||
!installedPluginIds.has(entry.id) &&
|
||||
manifestInstalledIds.has(entry.id as ChannelChoice) &&
|
||||
shouldShowChannelInSetup(entry.meta),
|
||||
);
|
||||
const installableCatalogEntries = installableCatalogEntriesSource.filter(
|
||||
(entry) =>
|
||||
!installedPluginIds.has(entry.id) &&
|
||||
!manifestInstalledIds.has(entry.id as ChannelChoice) &&
|
||||
shouldShowChannelInSetup(entry.meta),
|
||||
);
|
||||
const installedCatalogEntries = installedCatalogEntriesSource
|
||||
.filter(
|
||||
(entry) =>
|
||||
!installedPluginIds.has(entry.id) &&
|
||||
manifestInstalledIds.has(entry.id as ChannelChoice) &&
|
||||
shouldShowChannelInSetup(entry.meta),
|
||||
)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
meta: normalizeChannelMeta({
|
||||
id: entry.id as ChannelChoice,
|
||||
meta: entry.meta,
|
||||
}),
|
||||
}));
|
||||
const installableCatalogEntries = installableCatalogEntriesSource
|
||||
.filter(
|
||||
(entry) =>
|
||||
!installedPluginIds.has(entry.id) &&
|
||||
!manifestInstalledIds.has(entry.id as ChannelChoice) &&
|
||||
shouldShowChannelInSetup(entry.meta),
|
||||
)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
meta: normalizeChannelMeta({
|
||||
id: entry.id as ChannelChoice,
|
||||
meta: entry.meta,
|
||||
}),
|
||||
}));
|
||||
|
||||
const metaById = new Map<string, ChannelMeta>();
|
||||
for (const meta of listChatChannels()) {
|
||||
metaById.set(meta.id, meta);
|
||||
metaById.set(
|
||||
meta.id,
|
||||
normalizeChannelMeta({
|
||||
id: meta.id,
|
||||
meta,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const plugin of params.installedPlugins) {
|
||||
metaById.set(plugin.id, plugin.meta);
|
||||
metaById.set(
|
||||
plugin.id,
|
||||
normalizeChannelMeta({
|
||||
id: plugin.id,
|
||||
meta: plugin.meta,
|
||||
existing: metaById.get(plugin.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const entry of installedCatalogEntries) {
|
||||
if (!metaById.has(entry.id)) {
|
||||
metaById.set(entry.id, entry.meta);
|
||||
metaById.set(
|
||||
entry.id,
|
||||
normalizeChannelMeta({
|
||||
id: entry.id as ChannelChoice,
|
||||
meta: entry.meta,
|
||||
existing: metaById.get(entry.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const entry of installableCatalogEntries) {
|
||||
if (!metaById.has(entry.id)) {
|
||||
metaById.set(entry.id, entry.meta);
|
||||
metaById.set(
|
||||
entry.id,
|
||||
normalizeChannelMeta({
|
||||
id: entry.id as ChannelChoice,
|
||||
meta: entry.meta,
|
||||
existing: metaById.get(entry.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -772,6 +772,93 @@ describe("setupChannels", () => {
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render undefined primer lines for malformed external setup plugins", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "external-chat",
|
||||
source: "test",
|
||||
plugin: {
|
||||
...createChannelTestPluginBase({
|
||||
id: "external-chat",
|
||||
label: "External Chat",
|
||||
docsPath: "/channels/external-chat",
|
||||
}),
|
||||
meta: {
|
||||
id: "external-chat",
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const note = vi.fn(async (_message?: string, _title?: string) => {});
|
||||
const select = vi.fn(async () => "__done__");
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
|
||||
const prompter = createPrompter({
|
||||
note,
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
await runSetupChannels({} as OpenClawConfig, prompter);
|
||||
|
||||
const primerMessage =
|
||||
note.mock.calls.find(([, title]) => title === "How channels work")?.[0] ?? "";
|
||||
expect(primerMessage).toContain("external-chat:");
|
||||
expect(primerMessage).not.toContain("undefined: undefined");
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps malformed external setup plugins selectable without undefined labels", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "external-chat",
|
||||
source: "test",
|
||||
plugin: {
|
||||
...createChannelTestPluginBase({
|
||||
id: "external-chat",
|
||||
label: "External Chat",
|
||||
docsPath: "/channels/external-chat",
|
||||
}),
|
||||
meta: {
|
||||
id: "external-chat",
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const note = vi.fn(async (_message?: string, _title?: string) => {});
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => {
|
||||
if (message === "Select a channel") {
|
||||
const external = (options as Array<{ value: string; label?: string; hint?: string }>).find(
|
||||
(entry) => entry.value === "external-chat",
|
||||
);
|
||||
expect(external?.label).toBe("external-chat");
|
||||
expect(external?.hint ?? "").not.toContain("undefined");
|
||||
return "__done__";
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
|
||||
const prompter = createPrompter({
|
||||
note,
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
await runSetupChannels({} as OpenClawConfig, prompter);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps configured external plugin channels visible when the active registry starts empty", async () => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([createMSTeamsCatalogEntry()]);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { listChatChannels } from "../channels/chat-meta.js";
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import {
|
||||
getChannelSetupPlugin,
|
||||
@@ -138,6 +137,12 @@ export async function setupChannels(
|
||||
}
|
||||
return Array.from(merged.values());
|
||||
};
|
||||
const resolveVisibleChannelEntries = () =>
|
||||
resolveChannelSetupEntries({
|
||||
cfg: next,
|
||||
installedPlugins: listVisibleInstalledPlugins(),
|
||||
workspaceDir: resolveWorkspaceDir(),
|
||||
});
|
||||
const loadScopedChannelPlugin = async (
|
||||
channel: ChannelChoice,
|
||||
pluginId?: string,
|
||||
@@ -191,13 +196,7 @@ export async function setupChannels(
|
||||
};
|
||||
await preloadConfiguredExternalPlugins();
|
||||
|
||||
const {
|
||||
installedPlugins,
|
||||
catalogEntries,
|
||||
installedCatalogEntries,
|
||||
statusByChannel,
|
||||
statusLines,
|
||||
} = await collectChannelStatus({
|
||||
const { statusByChannel, statusLines } = await collectChannelStatus({
|
||||
cfg: next,
|
||||
options,
|
||||
accountOverrides,
|
||||
@@ -218,38 +217,11 @@ export async function setupChannels(
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const corePrimer = listChatChannels()
|
||||
.filter((meta) => shouldShowChannelInSetup(meta))
|
||||
.map((meta) => ({
|
||||
id: meta.id,
|
||||
label: meta.label,
|
||||
blurb: meta.blurb,
|
||||
}));
|
||||
const coreIds = new Set(corePrimer.map((entry) => entry.id));
|
||||
const primerChannels = [
|
||||
...corePrimer,
|
||||
...installedPlugins
|
||||
.filter((plugin) => !coreIds.has(plugin.id))
|
||||
.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
label: plugin.meta.label,
|
||||
blurb: plugin.meta.blurb,
|
||||
})),
|
||||
...installedCatalogEntries
|
||||
.filter((entry) => !coreIds.has(entry.id as ChannelChoice))
|
||||
.map((entry) => ({
|
||||
id: entry.id as ChannelChoice,
|
||||
label: entry.meta.label,
|
||||
blurb: entry.meta.blurb,
|
||||
})),
|
||||
...catalogEntries
|
||||
.filter((entry) => !coreIds.has(entry.id as ChannelChoice))
|
||||
.map((entry) => ({
|
||||
id: entry.id as ChannelChoice,
|
||||
label: entry.meta.label,
|
||||
blurb: entry.meta.blurb,
|
||||
})),
|
||||
];
|
||||
const primerChannels = resolveVisibleChannelEntries().entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.meta.label,
|
||||
blurb: entry.meta.blurb,
|
||||
}));
|
||||
await noteChannelPrimer(prompter, primerChannels);
|
||||
|
||||
const quickstartDefault =
|
||||
@@ -302,11 +274,7 @@ export async function setupChannels(
|
||||
};
|
||||
|
||||
const getChannelEntries = () => {
|
||||
const resolved = resolveChannelSetupEntries({
|
||||
cfg: next,
|
||||
installedPlugins: listVisibleInstalledPlugins(),
|
||||
workspaceDir: resolveWorkspaceDir(),
|
||||
});
|
||||
const resolved = resolveVisibleChannelEntries();
|
||||
return {
|
||||
entries: resolved.entries,
|
||||
catalogById: resolved.installableCatalogById,
|
||||
|
||||
117
src/plugins/channel-validation.test.ts
Normal file
117
src/plugins/channel-validation.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getChatChannelMeta } from "../channels/chat-meta.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.public.js";
|
||||
import { normalizeRegisteredChannelPlugin } from "./channel-validation.js";
|
||||
import type { PluginDiagnostic } from "./types.js";
|
||||
|
||||
function collectDiagnostics() {
|
||||
const diagnostics: PluginDiagnostic[] = [];
|
||||
return {
|
||||
diagnostics,
|
||||
pushDiagnostic: (diag: PluginDiagnostic) => {
|
||||
diagnostics.push(diag);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createChannelPlugin(overrides?: Partial<ChannelPlugin>): ChannelPlugin {
|
||||
return {
|
||||
id: "demo",
|
||||
meta: {
|
||||
id: "demo",
|
||||
label: "Demo",
|
||||
selectionLabel: "Demo",
|
||||
docsPath: "/channels/demo",
|
||||
blurb: "demo channel",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" }),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("normalizeRegisteredChannelPlugin", () => {
|
||||
it("fills bundled channel metadata from the canonical core baseline", () => {
|
||||
const { diagnostics, pushDiagnostic } = collectDiagnostics();
|
||||
|
||||
const normalized = normalizeRegisteredChannelPlugin({
|
||||
pluginId: "demo-plugin",
|
||||
source: "/tmp/demo/index.ts",
|
||||
plugin: createChannelPlugin({
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
} as never,
|
||||
}),
|
||||
pushDiagnostic,
|
||||
});
|
||||
|
||||
const telegram = getChatChannelMeta("telegram");
|
||||
expect(normalized?.meta).toMatchObject({
|
||||
label: telegram.label,
|
||||
selectionLabel: telegram.selectionLabel,
|
||||
docsPath: telegram.docsPath,
|
||||
blurb: telegram.blurb,
|
||||
});
|
||||
expect(diagnostics.map((diag) => diag.message)).toEqual([
|
||||
'channel "telegram" registered incomplete metadata; filled missing label, selectionLabel, docsPath, blurb',
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the channel id for external channels with incomplete metadata", () => {
|
||||
const { diagnostics, pushDiagnostic } = collectDiagnostics();
|
||||
|
||||
const normalized = normalizeRegisteredChannelPlugin({
|
||||
pluginId: "demo-plugin",
|
||||
source: "/tmp/demo/index.ts",
|
||||
plugin: createChannelPlugin({
|
||||
id: "external-chat",
|
||||
meta: {
|
||||
id: "external-chat",
|
||||
} as never,
|
||||
}),
|
||||
pushDiagnostic,
|
||||
});
|
||||
|
||||
expect(normalized?.id).toBe("external-chat");
|
||||
expect(normalized?.meta).toMatchObject({
|
||||
id: "external-chat",
|
||||
label: "external-chat",
|
||||
selectionLabel: "external-chat",
|
||||
docsPath: "/channels/external-chat",
|
||||
blurb: "",
|
||||
});
|
||||
expect(diagnostics.map((diag) => diag.message)).toEqual([
|
||||
'channel "external-chat" registered incomplete metadata; filled missing label, selectionLabel, docsPath, blurb',
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns and repairs mismatched meta ids", () => {
|
||||
const { diagnostics, pushDiagnostic } = collectDiagnostics();
|
||||
|
||||
const normalized = normalizeRegisteredChannelPlugin({
|
||||
pluginId: "demo-plugin",
|
||||
source: "/tmp/demo/index.ts",
|
||||
plugin: createChannelPlugin({
|
||||
id: "demo",
|
||||
meta: {
|
||||
id: "other-demo",
|
||||
label: "Demo",
|
||||
selectionLabel: "Demo",
|
||||
docsPath: "/channels/demo",
|
||||
blurb: "demo channel",
|
||||
},
|
||||
}),
|
||||
pushDiagnostic,
|
||||
});
|
||||
|
||||
expect(normalized?.id).toBe("demo");
|
||||
expect(normalized?.meta.id).toBe("demo");
|
||||
expect(diagnostics.map((diag) => diag.message)).toEqual([
|
||||
'channel "demo" meta.id mismatch ("other-demo"); using registered channel id',
|
||||
]);
|
||||
});
|
||||
});
|
||||
100
src/plugins/channel-validation.ts
Normal file
100
src/plugins/channel-validation.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { listChatChannels } from "../channels/chat-meta.js";
|
||||
import { normalizeChannelMeta } from "../channels/plugins/meta-normalization.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { ChannelMeta } from "../channels/plugins/types.public.js";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "../shared/string-coerce.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
|
||||
function pushChannelDiagnostic(params: {
|
||||
level: PluginDiagnostic["level"];
|
||||
pluginId: string;
|
||||
source: string;
|
||||
message: string;
|
||||
pushDiagnostic: (diag: PluginDiagnostic) => void;
|
||||
}) {
|
||||
params.pushDiagnostic({
|
||||
level: params.level,
|
||||
pluginId: params.pluginId,
|
||||
source: params.source,
|
||||
message: params.message,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBundledChannelMeta(id: string): ChannelMeta | undefined {
|
||||
return listChatChannels().find((meta) => meta.id === id);
|
||||
}
|
||||
|
||||
function collectMissingChannelMetaFields(meta?: Partial<ChannelMeta> | null): string[] {
|
||||
const missing: string[] = [];
|
||||
if (!normalizeOptionalString(meta?.label)) {
|
||||
missing.push("label");
|
||||
}
|
||||
if (!normalizeOptionalString(meta?.selectionLabel)) {
|
||||
missing.push("selectionLabel");
|
||||
}
|
||||
if (!normalizeOptionalString(meta?.docsPath)) {
|
||||
missing.push("docsPath");
|
||||
}
|
||||
if (typeof meta?.blurb !== "string") {
|
||||
missing.push("blurb");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function normalizeRegisteredChannelPlugin(params: {
|
||||
pluginId: string;
|
||||
source: string;
|
||||
plugin: ChannelPlugin;
|
||||
pushDiagnostic: (diag: PluginDiagnostic) => void;
|
||||
}): ChannelPlugin | null {
|
||||
const id =
|
||||
normalizeOptionalString(params.plugin?.id) ??
|
||||
normalizeStringifiedOptionalString(params.plugin?.id) ??
|
||||
"";
|
||||
if (!id) {
|
||||
pushChannelDiagnostic({
|
||||
level: "error",
|
||||
pluginId: params.pluginId,
|
||||
source: params.source,
|
||||
message: "channel registration missing id",
|
||||
pushDiagnostic: params.pushDiagnostic,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawMeta = params.plugin.meta as Partial<ChannelMeta> | undefined;
|
||||
const rawMetaId = normalizeOptionalString(rawMeta?.id);
|
||||
if (rawMetaId && rawMetaId !== id) {
|
||||
pushChannelDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: params.pluginId,
|
||||
source: params.source,
|
||||
message: `channel "${id}" meta.id mismatch ("${rawMetaId}"); using registered channel id`,
|
||||
pushDiagnostic: params.pushDiagnostic,
|
||||
});
|
||||
}
|
||||
|
||||
const missingFields = collectMissingChannelMetaFields(rawMeta);
|
||||
if (missingFields.length > 0) {
|
||||
pushChannelDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: params.pluginId,
|
||||
source: params.source,
|
||||
message: `channel "${id}" registered incomplete metadata; filled missing ${missingFields.join(", ")}`,
|
||||
pushDiagnostic: params.pushDiagnostic,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...params.plugin,
|
||||
id,
|
||||
meta: normalizeChannelMeta({
|
||||
id,
|
||||
meta: rawMeta,
|
||||
existing: resolveBundledChannelMeta(id),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -2396,6 +2396,52 @@ module.exports = { id: "throws-after-import", register() {} };`,
|
||||
expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("repairs incomplete registered channel metadata before storing registry entries", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "channel-meta-repair",
|
||||
filename: "channel-meta-repair.cjs",
|
||||
body: `module.exports = { id: "channel-meta-repair", register(api) {
|
||||
api.registerChannel({
|
||||
plugin: {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram"
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" })
|
||||
},
|
||||
outbound: { deliveryMode: "direct" }
|
||||
}
|
||||
});
|
||||
} };`,
|
||||
});
|
||||
|
||||
const registry = loadRegistryFromSinglePlugin({
|
||||
plugin,
|
||||
pluginConfig: {
|
||||
allow: ["channel-meta-repair"],
|
||||
},
|
||||
});
|
||||
|
||||
const telegram = registry.channels.find((entry) => entry.plugin.id === "telegram")?.plugin;
|
||||
expect(telegram?.meta).toMatchObject({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
});
|
||||
expect(
|
||||
registry.diagnostics.some(
|
||||
(diag) =>
|
||||
diag.level === "warn" &&
|
||||
diag.message ===
|
||||
'channel "telegram" registered incomplete metadata; filled missing label, selectionLabel, docsPath, blurb',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("throws when strict plugin loading sees plugin errors", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
|
||||
@@ -18,12 +18,10 @@ import {
|
||||
} from "../infra/node-commands.js";
|
||||
import { normalizePluginGatewayMethodScope } from "../shared/gateway-method-policy.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "../shared/string-coerce.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { buildPluginApi } from "./api-builder.js";
|
||||
import { normalizeRegisteredChannelPlugin } from "./channel-validation.js";
|
||||
import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js";
|
||||
import {
|
||||
getRegisteredCompactionProvider,
|
||||
@@ -449,18 +447,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
typeof (registration as OpenClawPluginChannelRegistration).plugin === "object"
|
||||
? (registration as OpenClawPluginChannelRegistration)
|
||||
: { plugin: registration as ChannelPlugin };
|
||||
const plugin = normalized.plugin;
|
||||
const id =
|
||||
normalizeOptionalString(plugin?.id) ?? normalizeStringifiedOptionalString(plugin?.id) ?? "";
|
||||
if (!id) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "channel registration missing id",
|
||||
});
|
||||
const plugin = normalizeRegisteredChannelPlugin({
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
plugin: normalized.plugin,
|
||||
pushDiagnostic,
|
||||
});
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
const id = plugin.id;
|
||||
const existingRuntime = registry.channels.find((entry) => entry.plugin.id === id);
|
||||
if (mode !== "setup-only" && existingRuntime) {
|
||||
pushDiagnostic({
|
||||
|
||||
Reference in New Issue
Block a user