diff --git a/extensions/telegram/src/doctor.test.ts b/extensions/telegram/src/doctor.test.ts index 96e999578a35..af8fa7aa830b 100644 --- a/extensions/telegram/src/doctor.test.ts +++ b/extensions/telegram/src/doctor.test.ts @@ -5,12 +5,14 @@ import { collectTelegramApiRootWarnings, collectTelegramEmptyAllowlistExtraWarnings, collectTelegramGroupPolicyWarnings, + collectTelegramMalformedGroupsWarnings, collectTelegramMissingEnvTokenWarnings, collectTelegramSelectedQuoteToolProgressWarnings, maybeRepairTelegramApiRoots, maybeRepairTelegramAllowFromUsernames, scanTelegramBotEndpointApiRoots, scanTelegramInvalidAllowFromEntries, + scanTelegramMalformedGroupsConfig, scanTelegramSelectedQuoteToolProgressWarnings, telegramDoctor, } from "./doctor.js"; @@ -206,6 +208,42 @@ describe("telegram doctor", () => { ).toHaveLength(1); }); + it("warns when Telegram groups use a non-object shape", async () => { + const cfg = { + channels: { + telegram: { + groups: ["-1001234567890"], + accounts: { + work: { + groups: null, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const hits = scanTelegramMalformedGroupsConfig(cfg); + expect(hits).toEqual([ + { path: "channels.telegram.groups", actualType: "array" }, + { path: "channels.telegram.accounts.work.groups", actualType: "null" }, + ]); + + const warnings = collectTelegramMalformedGroupsWarnings({ + hits, + doctorFixCommand: "openclaw doctor --fix", + }); + expect(warnings[0]).toContain("object map keyed by Telegram group/chat id"); + expect(warnings[1]).toContain('channels.telegram.groups."-1001234567890".topics."99"'); + expect(warnings[1]).toContain("openclaw doctor --fix"); + + expect( + await telegramDoctor.collectPreviewWarnings?.({ + cfg, + doctorFixCommand: "openclaw doctor --fix", + }), + ).toEqual(expect.arrayContaining(warnings)); + }); + it("repairs @username entries to numeric ids", async () => { lookupTelegramChatIdMock.mockResolvedValue("111"); diff --git a/extensions/telegram/src/doctor.ts b/extensions/telegram/src/doctor.ts index 2876fb2fd9a9..d1da958d6e2d 100644 --- a/extensions/telegram/src/doctor.ts +++ b/extensions/telegram/src/doctor.ts @@ -26,6 +26,7 @@ import { import { resolveTelegramPreviewStreamMode } from "./preview-streaming.js"; type TelegramAllowFromInvalidHit = { path: string; entry: string }; +type TelegramMalformedGroupsHit = { path: string; actualType: string }; type TelegramSelectedQuoteToolProgressHit = { path: string; replyToMode: string }; type TelegramApiRootBotEndpointHit = { path: string; @@ -131,6 +132,53 @@ function collectTelegramAllowFromLists( return refs; } +function describeConfigValueType(value: unknown): string { + if (Array.isArray(value)) { + return "array"; + } + if (value === null) { + return "null"; + } + return typeof value; +} + +export function scanTelegramMalformedGroupsConfig( + cfg: OpenClawConfig, +): TelegramMalformedGroupsHit[] { + const hits: TelegramMalformedGroupsHit[] = []; + for (const scope of collectTelegramAccountScopes(cfg)) { + if (!Object.prototype.hasOwnProperty.call(scope.account, "groups")) { + continue; + } + const groups = scope.account.groups; + if (asObjectRecord(groups)) { + continue; + } + hits.push({ + path: `${scope.prefix}.groups`, + actualType: describeConfigValueType(groups), + }); + } + return hits; +} + +export function collectTelegramMalformedGroupsWarnings(params: { + hits: TelegramMalformedGroupsHit[]; + doctorFixCommand: string; +}): string[] { + if (params.hits.length === 0) { + return []; + } + const sample = params.hits[0] ?? { + path: "channels.telegram.groups", + actualType: "unknown", + }; + return [ + `- ${sanitizeForLog(sample.path)} has invalid Telegram groups shape (${sanitizeForLog(sample.actualType)}); expected an object map keyed by Telegram group/chat id, not an array, string, or null.`, + `- Example shape: channels.telegram.groups."-1001234567890".topics."99" = { agentId: "support" }. Use topics for forum-topic routing, then rerun ${params.doctorFixCommand} for any remaining Telegram config cleanup.`, + ]; +} + export function scanTelegramInvalidAllowFromEntries( cfg: OpenClawConfig, ): TelegramAllowFromInvalidHit[] { @@ -557,6 +605,10 @@ export const telegramDoctor: ChannelDoctorAdapter = { normalizeCompatibilityConfig: normalizeTelegramCompatibilityConfig, collectPreviewWarnings: ({ cfg, doctorFixCommand, env }) => [ ...collectTelegramMissingEnvTokenWarnings({ cfg, env }), + ...collectTelegramMalformedGroupsWarnings({ + hits: scanTelegramMalformedGroupsConfig(cfg), + doctorFixCommand, + }), ...collectTelegramInvalidAllowFromWarnings({ hits: scanTelegramInvalidAllowFromEntries(cfg), doctorFixCommand, diff --git a/src/config/validation.channel-metadata.test.ts b/src/config/validation.channel-metadata.test.ts index aa6e18e88469..6ad607299a3f 100644 --- a/src/config/validation.channel-metadata.test.ts +++ b/src/config/validation.channel-metadata.test.ts @@ -246,6 +246,28 @@ describe("validateConfigObjectRawWithPlugins channel metadata", () => { expect(result.ok).toBe(true); }); + + it("keeps raw channel validation diagnostics plugin-agnostic", () => { + const result = validateConfigObjectRawWithPlugins({ + channels: { + telegram: { + groups: ["-1001234567890"], + }, + }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues).toContainEqual( + expect.objectContaining({ + path: "channels.telegram.groups", + message: expect.stringContaining("invalid config:"), + }), + ); + expect(result.issues[0]?.message).not.toContain("Telegram groups"); + expect(result.issues[0]?.message).not.toContain("openclaw doctor --fix"); + } + }); }); describe("validateConfigObjectRawWithPlugins plugin config defaults", () => { diff --git a/src/config/validation.ts b/src/config/validation.ts index eb2527423743..2a7144d6fc25 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -231,6 +231,9 @@ function collectAllowedValuesFromBundledChannelSchemaPath( return collectAllowedValuesFromJsonSchemaNode(targetNode); } +function formatRawChannelConfigIssueMessage(message: string): string { + return `invalid config: ${message}`; +} function collectRawBundledChannelConfigIssues(config: OpenClawConfig): ConfigValidationIssue[] { if (!config.channels || !isRecord(config.channels)) { return []; @@ -253,10 +256,11 @@ function collectRawBundledChannelConfigIssues(config: OpenClawConfig): ConfigVal const message = error.additionalProperty ? `${error.message}: "${error.additionalProperty}"` : error.message; + const path = + error.path === "" ? `channels.${channelId}` : `channels.${channelId}.${error.path}`; issues.push({ - path: - error.path === "" ? `channels.${channelId}` : `channels.${channelId}.${error.path}`, - message: `invalid config: ${message}`, + path, + message: formatRawChannelConfigIssueMessage(message), allowedValues: error.allowedValues, allowedValuesHiddenCount: error.allowedValuesHiddenCount, }); @@ -1401,10 +1405,11 @@ function validateConfigObjectWithPluginsBase( }); if (!result.ok) { for (const error of result.errors) { + const path = + error.path === "" ? `channels.${trimmed}` : `channels.${trimmed}.${error.path}`; issues.push({ - path: - error.path === "" ? `channels.${trimmed}` : `channels.${trimmed}.${error.path}`, - message: `invalid config: ${error.message}`, + path, + message: formatRawChannelConfigIssueMessage(error.message), allowedValues: error.allowedValues, allowedValuesHiddenCount: error.allowedValuesHiddenCount, });