refactor: move Telegram and iMessage config schemas to plugins (#112850)

* refactor: move telegram and imessage schemas to plugins

* test: allow bundled schema facade regression coverage

* refactor: remove orphaned command config helper
This commit is contained in:
Peter Steinberger
2026-07-22 23:01:47 -04:00
committed by GitHub
parent 099d6351b3
commit ebec398543
22 changed files with 640 additions and 707 deletions

View File

@@ -22,7 +22,7 @@ ad60ccc4fe9084d47f0477e02d9296bacad32f26d7456e2a84be8d25a53a25c2 module/boolean
6e3b8ccee738f8eddde5d813b6e204d6f7c8541fda4cb279a3be3ead27f47e7d module/channel-actions
00d326fdff396dfcc657d1fd16a4015b81d60ae8bf7dad09ac1010b376814de6 module/channel-config-helpers
16dc9d32e8ca3ef78fc63e0fc4b20e6b943633e9572de1a49d24a880b6ffc66c module/channel-config-primitives
8e51280de9fbf0cb51f4738130ae40df251d0055f7abb78e3ec1245534025d0f module/channel-config-schema
c0f910ebfa3dbf283145fb1e3b9c016d03e853ef13e70402b09f3b9d9c2f4ab0 module/channel-config-schema
2dd98659d9600e755f09ef00dd91c36692b8562ed3700461937e94f6cd1e640a module/channel-contract
82a966cf4c343179eea76afdcc982cd814a80048b3cbee95844dcddb64a949dd module/channel-core
fbf353eb38ae68d8ded3f2a60b432c7bb2c245d2ec7e7c9f53c6da19a0db0938 module/channel-entry-contract

View File

@@ -1,3 +1,3 @@
// Imessage API module exposes the plugin public contract.
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
export { IMessageConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema";
export { IMessageConfigSchema } from "./src/config-schema.js";

View File

@@ -5,7 +5,8 @@
"description": "OpenClaw iMessage channel plugin using imsg on a signed-in Mac",
"type": "module",
"dependencies": {
"typebox": "1.3.3"
"typebox": "1.3.3",
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -24,6 +24,14 @@ describe("imessage config schema", () => {
}
});
it("accepts account allowlist policy inherited from the channel", () => {
const result = IMessageConfigSchema.safeParse({
allowFrom: ["alice"],
accounts: { work: { dmPolicy: "allowlist" } },
});
expect(result.success).toBe(true);
});
it("defaults dm/group policy", () => {
const res = IMessageConfigSchema.safeParse({});

View File

@@ -1,7 +1,138 @@
// Imessage helper module supports config schema behavior.
import { buildChannelConfigSchema, IMessageConfigSchema } from "../config-api.js";
// iMessage helper module supports config schema behavior.
import {
buildChannelConfigSchema,
buildChannelReactionShape,
buildCommonChannelAccountShape,
buildGroupEntrySchema,
ChannelDeliveryStreamingConfigSchema,
ChannelSendReadReceiptsSchema,
ExecutableTokenSchema,
isSafeScpRemoteHost,
isValidInboundPathRootPattern,
requireAllowlistAllowFrom,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
import { iMessageChannelConfigUiHints } from "./config-ui-hints.js";
const IMessageActionSchema = z
.object({
reactions: z.boolean().optional(),
edit: z.boolean().optional(),
unsend: z.boolean().optional(),
reply: z.boolean().optional(),
sendWithEffect: z.boolean().optional(),
renameGroup: z.boolean().optional(),
setGroupIcon: z.boolean().optional(),
addParticipant: z.boolean().optional(),
removeParticipant: z.boolean().optional(),
leaveGroup: z.boolean().optional(),
sendAttachment: z.boolean().optional(),
polls: z.boolean().optional(),
})
.strict()
.optional();
const IMessageAccountSchemaBase = z
.object({
...buildCommonChannelAccountShape({
useDefaults: true,
omit: ["mentionPatterns", "replyToMode"],
streaming: ChannelDeliveryStreamingConfigSchema.optional(),
mediaMaxMb: z.number().int().positive().optional(),
}),
cliPath: ExecutableTokenSchema.optional(),
dbPath: z.string().optional(),
remoteHost: z
.string()
.refine(isSafeScpRemoteHost, "expected SSH host or user@host (no spaces/options)")
.optional(),
actions: IMessageActionSchema,
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
sendTransport: z.enum(["auto", "bridge", "applescript"]).optional(),
region: z.string().optional(),
includeAttachments: z.boolean().optional(),
attachmentRoots: z
.array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root"))
.optional(),
remoteAttachmentRoots: z
.array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root"))
.optional(),
probeTimeoutMs: z.number().int().positive().optional(),
sendReadReceipts: ChannelSendReadReceiptsSchema,
...buildChannelReactionShape({ notificationModes: ["off", "own", "all"] }),
catchup: z
.object({
enabled: z.boolean().optional(),
maxAgeMinutes: z.number().int().min(1).max(720).optional(),
perRunLimit: z.number().int().min(1).max(500).optional(),
firstRunLookbackMinutes: z.number().int().min(1).max(720).optional(),
maxFailureRetries: z.number().int().min(1).max(1000).optional(),
})
.strict()
.optional(),
groups: z
.record(
z.string(),
buildGroupEntrySchema(undefined, {
omit: ["skills", "enabled", "allowFrom"],
}).optional(),
)
.optional(),
})
.strict();
export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
// Account-level schemas skip allowFrom validation because accounts inherit
// allowFrom from the parent channel config at runtime.
accounts: z.record(z.string(), IMessageAccountSchemaBase.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"',
});
requireAllowlistAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.imessage.dmPolicy="allowlist" requires channels.imessage.allowFrom to contain at least one sender ID',
});
if (!value.accounts) {
return;
}
for (const [accountId, account] of Object.entries(value.accounts)) {
if (!account) {
continue;
}
const effectivePolicy = account.dmPolicy ?? value.dmPolicy;
const effectiveAllowFrom = account.allowFrom ?? value.allowFrom;
requireOpenAllowFrom({
policy: effectivePolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.imessage.accounts.*.dmPolicy="open" requires channels.imessage.accounts.*.allowFrom (or channels.imessage.allowFrom) to include "*"',
});
requireAllowlistAllowFrom({
policy: effectivePolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.imessage.accounts.*.dmPolicy="allowlist" requires channels.imessage.accounts.*.allowFrom (or channels.imessage.allowFrom) to contain at least one sender ID',
});
}
});
export const IMessageChannelConfigSchema = buildChannelConfigSchema(IMessageConfigSchema, {
uiHints: iMessageChannelConfigUiHints,
});

View File

@@ -1,6 +1,6 @@
// Telegram API module exposes the plugin public contract.
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
export { TelegramConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema";
export { TelegramConfigSchema } from "./src/config-schema.js";
export {
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,

View File

@@ -9,7 +9,8 @@
"@grammyjs/transformer-throttler": "1.2.1",
"grammy": "1.44.0",
"typebox": "1.3.3",
"undici": "8.5.0"
"undici": "8.5.0",
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -31,6 +31,24 @@ describe("telegram custom commands schema", () => {
}
});
it('rejects dmPolicy="allowlist" without allowFrom', () => {
expectTelegramConfigIssue({ dmPolicy: "allowlist", botToken: "fake" }, "allowFrom");
});
it("accepts account allowlist policy inherited from the channel", () => {
expectTelegramConfigValid({
allowFrom: ["12345"],
accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } },
});
});
it("rejects account allowlist without account or channel allowFrom", () => {
expectTelegramConfigIssue(
{ accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } } },
"accounts.bot1.allowFrom",
);
});
it("defaults dm/group policy", () => {
const res = TelegramConfigSchema.safeParse({});

View File

@@ -1,7 +1,370 @@
// Telegram helper module supports config schema behavior.
import { buildChannelConfigSchema, TelegramConfigSchema } from "../config-api.js";
import {
buildChannelConfigSchema,
buildChannelExecApprovalsSchema,
buildChannelReactionShape,
buildCommonChannelAccountShape,
buildGroupEntrySchema,
ChannelPreviewStreamingConfigSchema,
ChannelStreamingPreviewSchema,
DmPolicySchema,
GroupPolicySchema,
ProviderCommandsSchema,
requireAllowlistAllowFrom,
requireOpenAllowFrom,
ToolPolicySchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import {
buildSecretInputSchema,
hasConfiguredSecretInput,
registerSensitiveConfigSchema,
} from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
import {
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
resolveTelegramCustomCommands,
} from "./command-config.js";
import { telegramChannelConfigUiHints } from "./config-ui-hints.js";
const SecretInputSchema = buildSecretInputSchema();
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
type TelegramAccountLike = {
enabled?: unknown;
webhookUrl?: unknown;
webhookSecret?: unknown;
};
function validateTelegramWebhookSecretRequirements(
value: {
webhookUrl?: unknown;
webhookSecret?: unknown;
accounts?: Record<string, TelegramAccountLike | undefined>;
},
ctx: z.RefinementCtx,
): void {
const baseWebhookUrl = normalizeOptionalString(value.webhookUrl) ?? "";
const hasBaseWebhookSecret = hasConfiguredSecretInput(value.webhookSecret);
if (baseWebhookUrl && !hasBaseWebhookSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "channels.telegram.webhookUrl requires channels.telegram.webhookSecret",
path: ["webhookSecret"],
});
}
for (const [accountId, account] of Object.entries(value.accounts ?? {})) {
if (!account || account.enabled === false) {
continue;
}
const accountWebhookUrl = normalizeOptionalString(account.webhookUrl) ?? "";
if (!accountWebhookUrl) {
continue;
}
if (!hasConfiguredSecretInput(account.webhookSecret) && !hasBaseWebhookSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"channels.telegram.accounts.*.webhookUrl requires channels.telegram.webhookSecret or channels.telegram.accounts.*.webhookSecret",
path: ["accounts", accountId, "webhookSecret"],
});
}
}
}
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
const TelegramCapabilitiesSchema = z.union([
z.array(z.string()),
z
.object({
inlineButtons: TelegramInlineButtonsScopeSchema.optional(),
})
.strict(),
]);
const TelegramPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
preview: ChannelStreamingPreviewSchema.optional(),
}).strict();
const TelegramErrorPolicySchema = z.enum(["always", "once", "silent"]).optional();
const TelegramTopicSchema = z
.object({
requireMention: z.boolean().optional(),
ingest: z.boolean().optional(),
disableAudioPreflight: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
agentId: z.string().optional(),
errorPolicy: TelegramErrorPolicySchema,
})
.strict();
const TelegramGroupSchema = buildGroupEntrySchema({
ingest: z.boolean().optional(),
disableAudioPreflight: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional(),
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
errorPolicy: TelegramErrorPolicySchema,
});
const AutoTopicLabelSchema = z
.union([
z.boolean(),
z
.object({
enabled: z.boolean().optional(),
prompt: z.string().optional(),
})
.strict(),
])
.optional();
const TelegramDirectSchema = z
.object({
dmPolicy: DmPolicySchema.optional(),
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
errorPolicy: TelegramErrorPolicySchema,
requireTopic: z.boolean().optional(),
autoTopicLabel: AutoTopicLabelSchema,
})
.strict();
const TelegramCustomCommandSchema = z
.object({
command: z.string().overwrite(normalizeTelegramCommandName),
description: z.string().overwrite(normalizeTelegramCommandDescription),
})
.strict();
const validateTelegramCustomCommands = (
value: { customCommands?: Array<{ command?: string; description?: string }> },
ctx: z.RefinementCtx,
) => {
if (!value.customCommands || value.customCommands.length === 0) {
return;
}
const { issues } = resolveTelegramCustomCommands({
commands: value.customCommands,
checkReserved: false,
checkDuplicates: false,
});
for (const issue of issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCommands", issue.index, issue.field],
message: issue.message,
});
}
};
const TelegramAccountSchemaBase = z
.object({
...buildCommonChannelAccountShape({
useDefaults: true,
capabilities: TelegramCapabilitiesSchema.optional(),
defaultTo: z.union([z.string(), z.number()]).optional(),
streaming: TelegramPreviewStreamingConfigSchema.optional(),
}),
execApprovals: buildChannelExecApprovalsSchema(z.union([z.string(), z.number()])),
commands: ProviderCommandsSchema,
customCommands: z.array(TelegramCustomCommandSchema).optional(),
botToken: registerSensitiveConfigSchema(SecretInputSchema.optional()),
tokenFile: z.string().optional(),
groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(),
direct: z.record(z.string(), TelegramDirectSchema.optional()).optional(),
richMessages: z.boolean().optional(),
network: z
.object({
autoSelectFamily: z.boolean().optional(),
dnsResultOrder: z.enum(["ipv4first", "verbatim"]).optional(),
dangerouslyAllowPrivateNetwork: z
.boolean()
.optional()
.describe(
"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.",
),
})
.strict()
.optional(),
proxy: z.string().optional(),
webhookUrl: z
.string()
.optional()
.describe(
"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.",
),
webhookSecret: registerSensitiveConfigSchema(
SecretInputSchema.optional().describe(
"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.",
),
),
webhookPath: z
.string()
.optional()
.describe(
"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.",
),
webhookHost: z
.string()
.optional()
.describe(
"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.",
),
webhookPort: z
.number()
.int()
.nonnegative()
.optional()
.describe(
"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.",
),
webhookCertPath: z
.string()
.optional()
.describe(
"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).",
),
actions: z
.object({
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
poll: z.boolean().optional(),
deleteMessage: z.boolean().optional(),
editMessage: z.boolean().optional(),
sticker: z.boolean().optional(),
createForumTopic: z.boolean().optional(),
editForumTopic: z.boolean().optional(),
})
.strict()
.optional(),
threadBindings: z
.object({
enabled: z.boolean().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
})
.strict()
.optional(),
...buildChannelReactionShape({
notificationModes: ["off", "own", "all"],
reactionLevels: ["off", "ack", "minimal", "extensive"],
ackReaction: z.string().optional(),
}),
linkPreview: z.boolean().optional(),
silentErrorReplies: z.boolean().optional(),
errorPolicy: TelegramErrorPolicySchema,
apiRoot: z.string().url().optional(),
trustedLocalFileRoots: z
.array(z.string())
.optional()
.describe(
"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.",
),
autoTopicLabel: AutoTopicLabelSchema,
})
.strict();
const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => {
// Account-level schemas skip allowFrom validation because accounts inherit
// allowFrom from the parent channel config at runtime (resolveTelegramAccount
// shallow-merges top-level and account values in src/telegram/accounts.ts).
// Validation is enforced at the top-level TelegramConfigSchema instead.
validateTelegramCustomCommands(value, ctx);
});
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
});
requireAllowlistAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.telegram.dmPolicy="allowlist" requires channels.telegram.allowFrom to contain at least one sender ID',
});
validateTelegramCustomCommands(value, ctx);
if (value.accounts) {
for (const [accountId, account] of Object.entries(value.accounts)) {
if (!account) {
continue;
}
const effectivePolicy = account.dmPolicy ?? value.dmPolicy;
const effectiveAllowFrom = account.allowFrom ?? value.allowFrom;
requireOpenAllowFrom({
policy: effectivePolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.telegram.accounts.*.dmPolicy="open" requires channels.telegram.accounts.*.allowFrom (or channels.telegram.allowFrom) to include "*"',
});
requireAllowlistAllowFrom({
policy: effectivePolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.telegram.accounts.*.dmPolicy="allowlist" requires channels.telegram.accounts.*.allowFrom (or channels.telegram.allowFrom) to contain at least one sender ID',
});
}
}
if (!value.accounts) {
validateTelegramWebhookSecretRequirements(value, ctx);
return;
}
for (const [accountId, account] of Object.entries(value.accounts)) {
if (!account) {
continue;
}
if (account.enabled === false) {
continue;
}
const effectiveDmPolicy = account.dmPolicy ?? value.dmPolicy;
const effectiveAllowFrom = Array.isArray(account.allowFrom)
? account.allowFrom
: value.allowFrom;
requireOpenAllowFrom({
policy: effectiveDmPolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.telegram.accounts.*.dmPolicy="open" requires channels.telegram.allowFrom or channels.telegram.accounts.*.allowFrom to include "*"',
});
requireAllowlistAllowFrom({
policy: effectiveDmPolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.telegram.accounts.*.dmPolicy="allowlist" requires channels.telegram.allowFrom or channels.telegram.accounts.*.allowFrom to contain at least one sender ID',
});
}
validateTelegramWebhookSecretRequirements(value, ctx);
});
export const TelegramChannelConfigSchema = buildChannelConfigSchema(TelegramConfigSchema, {
uiHints: telegramChannelConfigUiHints,
});

6
pnpm-lock.yaml generated
View File

@@ -983,6 +983,9 @@ importers:
typebox:
specifier: 1.3.3
version: 1.3.3
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
@@ -1794,6 +1797,9 @@ importers:
undici:
specifier: 8.5.0
version: 8.5.0
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*

View File

@@ -149,7 +149,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: channel-owned setup contract factory.
// +18: generic schema primitives needed by plugin-owned channel config schemas.
// +2: shared Teams reply-style and TTS schema leaves.
4698,
// +2: generic inbound-root and SCP-host schema validators.
4700,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -160,7 +161,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: channel-owned setup contract factory.
// +4: generic channel schema shape builders.
// +1: plugin-owned sensitive-schema registration.
2847,
// +2: generic inbound-root and SCP-host schema validators.
2849,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(

View File

@@ -1,6 +1,5 @@
// Regresses allowlist config requiring explicit allowFrom entries.
import { describe, expect, it } from "vitest";
import { IMessageConfigSchema, TelegramConfigSchema } from "./zod-schema.providers-core.js";
import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js";
function expectSchemaAllowlistIssue(
@@ -26,12 +25,6 @@ function expectSchemaAllowlistIssue(
describe('dmPolicy="allowlist" requires non-empty effective allowFrom', () => {
it.each([
{
name: "telegram",
schema: TelegramConfigSchema,
config: { dmPolicy: "allowlist", botToken: "fake" },
issuePath: "allowFrom",
},
{
name: "whatsapp",
schema: WhatsAppConfigSchema,
@@ -44,45 +37,19 @@ describe('dmPolicy="allowlist" requires non-empty effective allowFrom', () => {
expectSchemaAllowlistIssue(schema, config, issuePath);
},
);
it('accepts dmPolicy="pairing" without allowFrom', () => {
const res = TelegramConfigSchema.safeParse({ dmPolicy: "pairing", botToken: "fake" });
expect(res.success).toBe(true);
});
});
describe('account dmPolicy="allowlist" uses inherited allowFrom', () => {
it.each([
{
name: "telegram",
schema: TelegramConfigSchema,
config: {
allowFrom: ["12345"],
accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } },
},
},
{
name: "whatsapp",
schema: WhatsAppConfigSchema,
config: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } },
},
{
name: "imessage",
schema: IMessageConfigSchema,
config: { allowFrom: ["alice"], accounts: { work: { dmPolicy: "allowlist" } } },
},
] as const)(
"accepts $name account allowlist when parent allowFrom exists",
({ schema, config }) => {
expect(schema.safeParse(config).success).toBe(true);
},
);
it("rejects telegram account allowlist when neither account nor parent has allowFrom", () => {
expectSchemaAllowlistIssue(
TelegramConfigSchema,
{ accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } } },
"allowFrom",
);
});
});

View File

@@ -7,7 +7,6 @@ import { applyDerivedTags } from "./schema.tags.js";
import { applyResolvedConfigTierHints } from "./schema.tiers.js";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
import { OpenClawSchema } from "./zod-schema.js";
import { TelegramConfigSchema } from "./zod-schema.providers-core.js";
describe("config schema", () => {
type SchemaInput = NonNullable<Parameters<typeof buildConfigSchema>[0]>;
@@ -659,17 +658,6 @@ describe("config schema", () => {
).toBe(false);
});
it("accepts progress commentary for shared progress streaming config", () => {
expect(
TelegramConfigSchema.safeParse({
streaming: {
mode: "progress",
progress: { commentary: true },
},
}).success,
).toBe(true);
});
it("keeps per-agent model overrides limited to model selection", () => {
const result = OpenClawSchema.safeParse({
agents: {

View File

@@ -1,450 +0,0 @@
// Defines core provider schema fragments for config parsing.
import { isValidInboundPathRootPattern } from "@openclaw/media-core/inbound-path-policy";
import { z } from "zod";
import { buildGroupEntrySchema } from "../channels/plugins/config-schema.js";
import { isSafeScpRemoteHost } from "../infra/scp-host.js";
import {
normalizeCommandDescription,
normalizeSlashCommandName,
resolveCustomCommands,
} from "../shared/custom-command-config.js";
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
import {
ChannelSendReadReceiptsSchema,
buildChannelExecApprovalsSchema,
buildChannelReactionShape,
buildCommonChannelAccountShape,
ChannelPreviewStreamingConfigSchema,
ChannelStreamingPreviewSchema,
} from "./zod-schema.channel-messaging-common.js";
import {
ChannelDeliveryStreamingConfigSchema,
DmPolicySchema,
ExecutableTokenSchema,
GroupPolicySchema,
ProviderCommandsSchema,
SecretInputSchema,
requireAllowlistAllowFrom,
requireOpenAllowFrom,
} from "./zod-schema.core.js";
import { validateTelegramWebhookSecretRequirements } from "./zod-schema.secret-input-validation.js";
import { sensitive } from "./zod-schema.sensitive.js";
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
const TelegramCapabilitiesSchema = z.union([
z.array(z.string()),
z
.object({
inlineButtons: TelegramInlineButtonsScopeSchema.optional(),
})
.strict(),
]);
const TelegramPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
preview: ChannelStreamingPreviewSchema.optional(),
}).strict();
const TelegramErrorPolicySchema = z.enum(["always", "once", "silent"]).optional();
const TelegramCommandNamePattern = /^[a-z0-9_]{1,32}$/;
const TelegramCustomCommandConfig = {
label: "Telegram",
pattern: TelegramCommandNamePattern,
patternDescription: "use a-z, 0-9, underscore; max 32 chars",
} as const;
const TelegramTopicSchema = z
.object({
requireMention: z.boolean().optional(),
ingest: z.boolean().optional(),
disableAudioPreflight: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
agentId: z.string().optional(),
errorPolicy: TelegramErrorPolicySchema,
})
.strict();
const TelegramGroupSchema = buildGroupEntrySchema({
ingest: z.boolean().optional(),
disableAudioPreflight: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional(),
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
errorPolicy: TelegramErrorPolicySchema,
});
const AutoTopicLabelSchema = z
.union([
z.boolean(),
z
.object({
enabled: z.boolean().optional(),
prompt: z.string().optional(),
})
.strict(),
])
.optional();
const TelegramDirectSchema = z
.object({
dmPolicy: DmPolicySchema.optional(),
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
errorPolicy: TelegramErrorPolicySchema,
requireTopic: z.boolean().optional(),
autoTopicLabel: AutoTopicLabelSchema,
})
.strict();
const TelegramCustomCommandSchema = z
.object({
command: z.string().overwrite(normalizeSlashCommandName),
description: z.string().overwrite(normalizeCommandDescription),
})
.strict();
const validateTelegramCustomCommands = (
value: { customCommands?: Array<{ command?: string; description?: string }> },
ctx: z.RefinementCtx,
) => {
if (!value.customCommands || value.customCommands.length === 0) {
return;
}
const { issues } = resolveCustomCommands({
commands: value.customCommands,
checkReserved: false,
checkDuplicates: false,
config: TelegramCustomCommandConfig,
});
for (const issue of issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCommands", issue.index, issue.field],
message: issue.message,
});
}
};
const TelegramAccountSchemaBase = z
.object({
...buildCommonChannelAccountShape({
useDefaults: true,
capabilities: TelegramCapabilitiesSchema.optional(),
defaultTo: z.union([z.string(), z.number()]).optional(),
streaming: TelegramPreviewStreamingConfigSchema.optional(),
}),
execApprovals: buildChannelExecApprovalsSchema(z.union([z.string(), z.number()])),
commands: ProviderCommandsSchema,
customCommands: z.array(TelegramCustomCommandSchema).optional(),
botToken: SecretInputSchema.optional().register(sensitive),
tokenFile: z.string().optional(),
groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(),
direct: z.record(z.string(), TelegramDirectSchema.optional()).optional(),
richMessages: z.boolean().optional(),
network: z
.object({
autoSelectFamily: z.boolean().optional(),
dnsResultOrder: z.enum(["ipv4first", "verbatim"]).optional(),
dangerouslyAllowPrivateNetwork: z
.boolean()
.optional()
.describe(
"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.",
),
})
.strict()
.optional(),
proxy: z.string().optional(),
webhookUrl: z
.string()
.optional()
.describe(
"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.",
),
webhookSecret: SecretInputSchema.optional()
.describe(
"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.",
)
.register(sensitive),
webhookPath: z
.string()
.optional()
.describe(
"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.",
),
webhookHost: z
.string()
.optional()
.describe(
"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.",
),
webhookPort: z
.number()
.int()
.nonnegative()
.optional()
.describe(
"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.",
),
webhookCertPath: z
.string()
.optional()
.describe(
"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).",
),
actions: z
.object({
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
poll: z.boolean().optional(),
deleteMessage: z.boolean().optional(),
editMessage: z.boolean().optional(),
sticker: z.boolean().optional(),
createForumTopic: z.boolean().optional(),
editForumTopic: z.boolean().optional(),
})
.strict()
.optional(),
threadBindings: z
.object({
enabled: z.boolean().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
})
.strict()
.optional(),
...buildChannelReactionShape({
notificationModes: ["off", "own", "all"],
reactionLevels: ["off", "ack", "minimal", "extensive"],
ackReaction: z.string().optional(),
}),
linkPreview: z.boolean().optional(),
silentErrorReplies: z.boolean().optional(),
errorPolicy: TelegramErrorPolicySchema,
apiRoot: z.string().url().optional(),
trustedLocalFileRoots: z
.array(z.string())
.optional()
.describe(
"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.",
),
autoTopicLabel: AutoTopicLabelSchema,
})
.strict();
const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => {
// Account-level schemas skip allowFrom validation because accounts inherit
// allowFrom from the parent channel config at runtime (resolveTelegramAccount
// shallow-merges top-level and account values in src/telegram/accounts.ts).
// Validation is enforced at the top-level TelegramConfigSchema instead.
validateTelegramCustomCommands(value, ctx);
});
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
});
requireAllowlistAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.telegram.dmPolicy="allowlist" requires channels.telegram.allowFrom to contain at least one sender ID',
});
validateTelegramCustomCommands(value, ctx);
if (value.accounts) {
for (const [accountId, account] of Object.entries(value.accounts)) {
if (!account) {
continue;
}
const effectivePolicy = account.dmPolicy ?? value.dmPolicy;
const effectiveAllowFrom = account.allowFrom ?? value.allowFrom;
requireOpenAllowFrom({
policy: effectivePolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.telegram.accounts.*.dmPolicy="open" requires channels.telegram.accounts.*.allowFrom (or channels.telegram.allowFrom) to include "*"',
});
requireAllowlistAllowFrom({
policy: effectivePolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.telegram.accounts.*.dmPolicy="allowlist" requires channels.telegram.accounts.*.allowFrom (or channels.telegram.allowFrom) to contain at least one sender ID',
});
}
}
if (!value.accounts) {
validateTelegramWebhookSecretRequirements(value, ctx);
return;
}
for (const [accountId, account] of Object.entries(value.accounts)) {
if (!account) {
continue;
}
if (account.enabled === false) {
continue;
}
const effectiveDmPolicy = account.dmPolicy ?? value.dmPolicy;
const effectiveAllowFrom = Array.isArray(account.allowFrom)
? account.allowFrom
: value.allowFrom;
requireOpenAllowFrom({
policy: effectiveDmPolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.telegram.accounts.*.dmPolicy="open" requires channels.telegram.allowFrom or channels.telegram.accounts.*.allowFrom to include "*"',
});
requireAllowlistAllowFrom({
policy: effectiveDmPolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.telegram.accounts.*.dmPolicy="allowlist" requires channels.telegram.allowFrom or channels.telegram.accounts.*.allowFrom to contain at least one sender ID',
});
}
validateTelegramWebhookSecretRequirements(value, ctx);
});
const IMessageActionSchema = z
.object({
reactions: z.boolean().optional(),
edit: z.boolean().optional(),
unsend: z.boolean().optional(),
reply: z.boolean().optional(),
sendWithEffect: z.boolean().optional(),
renameGroup: z.boolean().optional(),
setGroupIcon: z.boolean().optional(),
addParticipant: z.boolean().optional(),
removeParticipant: z.boolean().optional(),
leaveGroup: z.boolean().optional(),
sendAttachment: z.boolean().optional(),
polls: z.boolean().optional(),
})
.strict()
.optional();
const IMessageAccountSchemaBase = z
.object({
...buildCommonChannelAccountShape({
useDefaults: true,
omit: ["mentionPatterns", "replyToMode"],
streaming: ChannelDeliveryStreamingConfigSchema.optional(),
mediaMaxMb: z.number().int().positive().optional(),
}),
cliPath: ExecutableTokenSchema.optional(),
dbPath: z.string().optional(),
remoteHost: z
.string()
.refine(isSafeScpRemoteHost, "expected SSH host or user@host (no spaces/options)")
.optional(),
actions: IMessageActionSchema,
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
sendTransport: z.enum(["auto", "bridge", "applescript"]).optional(),
region: z.string().optional(),
includeAttachments: z.boolean().optional(),
attachmentRoots: z
.array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root"))
.optional(),
remoteAttachmentRoots: z
.array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root"))
.optional(),
probeTimeoutMs: z.number().int().positive().optional(),
sendReadReceipts: ChannelSendReadReceiptsSchema,
...buildChannelReactionShape({ notificationModes: ["off", "own", "all"] }),
catchup: z
.object({
enabled: z.boolean().optional(),
maxAgeMinutes: z.number().int().min(1).max(720).optional(),
perRunLimit: z.number().int().min(1).max(500).optional(),
firstRunLookbackMinutes: z.number().int().min(1).max(720).optional(),
maxFailureRetries: z.number().int().min(1).max(1000).optional(),
})
.strict()
.optional(),
groups: z
.record(
z.string(),
buildGroupEntrySchema(undefined, {
omit: ["skills", "enabled", "allowFrom"],
}).optional(),
)
.optional(),
})
.strict();
export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
// Account-level schemas skip allowFrom validation because accounts inherit
// allowFrom from the parent channel config at runtime.
accounts: z.record(z.string(), IMessageAccountSchemaBase.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"',
});
requireAllowlistAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.imessage.dmPolicy="allowlist" requires channels.imessage.allowFrom to contain at least one sender ID',
});
if (!value.accounts) {
return;
}
for (const [accountId, account] of Object.entries(value.accounts)) {
if (!account) {
continue;
}
const effectivePolicy = account.dmPolicy ?? value.dmPolicy;
const effectiveAllowFrom = account.allowFrom ?? value.allowFrom;
requireOpenAllowFrom({
policy: effectivePolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.imessage.accounts.*.dmPolicy="open" requires channels.imessage.accounts.*.allowFrom (or channels.imessage.allowFrom) to include "*"',
});
requireAllowlistAllowFrom({
policy: effectivePolicy,
allowFrom: effectiveAllowFrom,
ctx,
path: ["accounts", accountId, "allowFrom"],
message:
'channels.imessage.accounts.*.dmPolicy="allowlist" requires channels.imessage.accounts.*.allowFrom (or channels.imessage.allowFrom) to contain at least one sender ID',
});
}
});

View File

@@ -1,63 +0,0 @@
// Validates secret input schema fragments shared by config sections.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { z } from "zod";
import { hasConfiguredSecretInput } from "./types.secrets.js";
type TelegramAccountLike = {
enabled?: unknown;
webhookUrl?: unknown;
webhookSecret?: unknown;
};
type TelegramConfigLike = {
webhookUrl?: unknown;
webhookSecret?: unknown;
accounts?: Record<string, TelegramAccountLike | undefined>;
};
// Only enabled accounts need per-account secret requirement checks.
function forEachEnabledAccount<T extends { enabled?: unknown }>(
accounts: Record<string, T | undefined> | undefined,
run: (accountId: string, account: T) => void,
): void {
if (!accounts) {
return;
}
for (const [accountId, account] of Object.entries(accounts)) {
if (!account || account.enabled === false) {
continue;
}
run(accountId, account);
}
}
/** Validates Telegram webhook URLs have a usable shared or account webhook secret. */
export function validateTelegramWebhookSecretRequirements(
value: TelegramConfigLike,
ctx: z.RefinementCtx,
): void {
const baseWebhookUrl = normalizeOptionalString(value.webhookUrl) ?? "";
const hasBaseWebhookSecret = hasConfiguredSecretInput(value.webhookSecret);
if (baseWebhookUrl && !hasBaseWebhookSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "channels.telegram.webhookUrl requires channels.telegram.webhookSecret",
path: ["webhookSecret"],
});
}
forEachEnabledAccount(value.accounts, (accountId, account) => {
const accountWebhookUrl = normalizeOptionalString(account.webhookUrl) ?? "";
if (!accountWebhookUrl) {
return;
}
const hasAccountSecret = hasConfiguredSecretInput(account.webhookSecret);
if (!hasAccountSecret && !hasBaseWebhookSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"channels.telegram.accounts.*.webhookUrl requires channels.telegram.webhookSecret or channels.telegram.accounts.*.webhookSecret",
path: ["accounts", accountId, "webhookSecret"],
});
}
});
}

View File

@@ -3,16 +3,10 @@ import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
const providersWhatsappImportMock = vi.hoisted(() => vi.fn());
const providersCoreImportMock = vi.hoisted(() => vi.fn());
describe("OpenClawSchema startup imports", () => {
beforeEach(() => {
providersWhatsappImportMock.mockClear();
providersCoreImportMock.mockClear();
vi.doMock("./zod-schema.providers-core.js", () => {
providersCoreImportMock();
return {};
});
vi.doMock("./zod-schema.providers-whatsapp.js", () => {
providersWhatsappImportMock();
return {};
@@ -40,7 +34,6 @@ describe("OpenClawSchema startup imports", () => {
});
expect(parsed.success).toBe(true);
expect(providersCoreImportMock).not.toHaveBeenCalled();
expect(providersWhatsappImportMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
// Bundled channel config schema tests cover lazy plugin-owned schema resolution.
import { z } from "zod";
import { IMessageConfigSchema, TelegramConfigSchema } from "./bundled-channel-config-schema.js";
import type { OpenClawConfig } from "./config-contracts.js";
describe("bundled channel config schema facade", () => {
it("resolves Telegram and iMessage schemas from bundled plugin public APIs", () => {
expect(TelegramConfigSchema.safeParse({ dmPolicy: "pairing" }).success).toBe(true);
expect(IMessageConfigSchema.safeParse({ dmPolicy: "pairing" }).success).toBe(true);
const extended = TelegramConfigSchema.safeExtend({ testOnly: z.literal(true) });
expect(extended.safeParse({ dmPolicy: "pairing", testOnly: true }).success).toBe(true);
type ChannelConfig = NonNullable<OpenClawConfig["channels"]>;
const telegramConfig: NonNullable<ChannelConfig["telegram"]> = TelegramConfigSchema.parse({});
const imessageConfig: NonNullable<ChannelConfig["imessage"]> = IMessageConfigSchema.parse({});
expect(telegramConfig).toBeDefined();
expect(imessageConfig).toBeDefined();
});
});

View File

@@ -6,6 +6,13 @@
* bundled channel schemas. Internal callers use this subpath only for the
* bundled provider schemas; generic primitives come from channel-config-schema.
*/
import type { ZodObject, ZodOptional, ZodType } from "zod";
import type { OpenClawConfig } from "./config-contracts.js";
import {
createLazyFacadeObjectValue,
loadBundledPluginPublicSurfaceModuleSync,
} from "./facade-loader.js";
export {
AllowFromListSchema,
ChannelGroupEntrySchema,
@@ -25,6 +32,47 @@ export {
requireOpenAllowFrom,
ToolPolicySchema,
} from "./channel-config-schema.js";
export { IMessageConfigSchema, TelegramConfigSchema } from "../config/zod-schema.providers-core.js";
type ChannelConfig = NonNullable<OpenClawConfig["channels"]>;
type ConfigSchemaShape<TOutput extends object> = {
-readonly [K in keyof TOutput]-?: Pick<TOutput, K> extends Required<Pick<TOutput, K>>
? ZodType<TOutput[K]>
: ZodOptional<ZodType<Exclude<TOutput[K], undefined>>>;
};
type BundledObjectConfigSchema<TOutput extends object> = ZodObject<ConfigSchemaShape<TOutput>>;
type BundledConfigSchemaModule<TOutput extends object> = Record<
string,
BundledObjectConfigSchema<TOutput>
>;
function loadBundledConfigSchema<TOutput extends object>(
dirName: string,
exportName: string,
): BundledObjectConfigSchema<TOutput> {
const schema = loadBundledPluginPublicSurfaceModuleSync<BundledConfigSchemaModule<TOutput>>({
dirName,
artifactBasename: "config-api.js",
})[exportName];
if (!schema) {
throw new Error(`Bundled plugin ${dirName} config API does not export ${exportName}`);
}
return schema;
}
export const IMessageConfigSchema = createLazyFacadeObjectValue<
BundledObjectConfigSchema<NonNullable<ChannelConfig["imessage"]>>
>(() =>
loadBundledConfigSchema<NonNullable<ChannelConfig["imessage"]>>(
"imessage",
"IMessageConfigSchema",
),
);
export const TelegramConfigSchema = createLazyFacadeObjectValue<
BundledObjectConfigSchema<NonNullable<ChannelConfig["telegram"]>>
>(() =>
loadBundledConfigSchema<NonNullable<ChannelConfig["telegram"]>>(
"telegram",
"TelegramConfigSchema",
),
);
export { GoogleChatConfigSchema } from "../config/zod-schema.providers-googlechat.js";
export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js";

View File

@@ -50,3 +50,5 @@ export {
} from "../config/zod-schema.channel-messaging-common.js";
export { ChannelImplicitMentionsSchema } from "../config/zod-schema.implicit-mentions.js";
export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
export { isSafeScpRemoteHost } from "../infra/scp-host.js";
export { isValidInboundPathRootPattern } from "@openclaw/media-core/inbound-path-policy";

View File

@@ -10,8 +10,8 @@ const REPO_ROOT = resolve(SRC_ROOT, "..");
const BUNDLED_EXTENSION_CONFIG_IMPORT_GUARDS = [
{
path: "extensions/telegram/src/config-schema.ts",
allowedSpecifier: "../config-api.js",
forbiddenSpecifier: "openclaw/plugin-sdk/channel-config-schema",
allowedSpecifier: "openclaw/plugin-sdk/channel-config-schema",
forbiddenSpecifier: "openclaw/plugin-sdk/bundled-channel-config-schema",
},
{
path: "extensions/discord/src/config-schema.ts",
@@ -30,8 +30,8 @@ const BUNDLED_EXTENSION_CONFIG_IMPORT_GUARDS = [
},
{
path: "extensions/imessage/src/config-schema.ts",
allowedSpecifier: "../config-api.js",
forbiddenSpecifier: "openclaw/plugin-sdk/channel-config-schema",
allowedSpecifier: "openclaw/plugin-sdk/channel-config-schema",
forbiddenSpecifier: "openclaw/plugin-sdk/bundled-channel-config-schema",
},
{
path: "extensions/whatsapp/src/config-schema.ts",

View File

@@ -140,16 +140,16 @@ describe("config footprint guardrails", () => {
});
it("keeps canonical nested streaming paths in channel-owned schemas", () => {
const source = readSource("src/config/zod-schema.providers-core.ts");
const telegramSource = readSource("extensions/telegram/src/config-schema.ts");
const discordSource = readSource("extensions/discord/src/config-schema.ts");
const msTeamsSource = readSource("extensions/msteams/src/config-schema.ts");
const slackSource = readSource("extensions/slack/src/config-schema.ts");
expect(source).toContain("streaming: TelegramPreviewStreamingConfigSchema.optional(),");
expect(telegramSource).toContain("streaming: TelegramPreviewStreamingConfigSchema.optional(),");
expect(discordSource).toContain("streaming: DiscordPreviewStreamingConfigSchema.optional(),");
expect(msTeamsSource).toContain("streaming: ChannelPreviewStreamingConfigSchema.optional(),");
expect(slackSource).toContain("streaming: SlackStreamingConfigSchema.optional(),");
for (const schemaSource of [source, discordSource, msTeamsSource, slackSource]) {
for (const schemaSource of [telegramSource, discordSource, msTeamsSource, slackSource]) {
expect(schemaSource).not.toContain(
'streamMode: z.enum(["replace", "status_final", "append"])',
);
@@ -196,18 +196,23 @@ describe("config footprint guardrails", () => {
);
const bundledSchemaExportBlocks = Array.from(
bundledSection.matchAll(
/export \{(?<exports>[^}]*)\} from "\.\.\/config\/zod-schema\.providers-(?:core|googlechat|whatsapp)\.js";/g,
/export \{(?<exports>[^}]*)\} from "\.\.\/config\/zod-schema\.providers-(?:googlechat|whatsapp)\.js";/g,
),
)
.map((match) => match.groups?.exports)
.filter((block): block is string => Boolean(block));
expect(bundledSchemaExportBlocks).toHaveLength(3);
const exportedSchemaNames = Array.from(
bundledSchemaExportBlocks.join("\n").matchAll(/\b([A-Z][A-Za-z0-9]+ConfigSchema)\b/g),
)
.map((match) => match[1])
.filter((name): name is string => Boolean(name))
.toSorted((left, right) => left.localeCompare(right));
expect(bundledSchemaExportBlocks).toHaveLength(2);
const lazySchemaNames = Array.from(
bundledSection.matchAll(/\bexport const ([A-Z][A-Za-z0-9]+ConfigSchema)\b/g),
(match) => match[1],
).filter((name): name is string => Boolean(name));
const exportedSchemaNames = [
...Array.from(
bundledSchemaExportBlocks.join("\n").matchAll(/\b([A-Z][A-Za-z0-9]+ConfigSchema)\b/g),
(match) => match[1],
).filter((name): name is string => Boolean(name)),
...lazySchemaNames,
].toSorted((left, right) => left.localeCompare(right));
expect(exportedSchemaNames).toEqual([
"GoogleChatConfigSchema",
@@ -220,6 +225,12 @@ describe("config footprint guardrails", () => {
}
expect(bundledSource).toContain("Bundled-channel config schemas");
expect(bundledSource).toContain("openclaw/plugin-sdk/channel-config-schema");
expect(bundledSource).toMatch(
/loadBundledConfigSchema<[^;]+?>\(\s*"imessage",\s*"IMessageConfigSchema",?\s*\)/u,
);
expect(bundledSource).toMatch(
/loadBundledConfigSchema<[^;]+?>\(\s*"telegram",\s*"TelegramConfigSchema",?\s*\)/u,
);
// The primitives facade re-exports the canonical channel-config-schema
// module; only bundled provider schemas bypass it.
const primitivesSource = readSource("src/plugin-sdk/channel-config-primitives.ts");
@@ -234,6 +245,8 @@ describe("config footprint guardrails", () => {
// channel-config-schema is the canonical internal module; the primitives
// and bundled shells stay export-compatible for plugins only.
const allowedShellImporters = new Set([
// The facade's focused regression test is its only internal consumer.
"src/plugin-sdk/bundled-channel-config-schema.test.ts",
// This guardrail file embeds facade specifiers in shell-shape assertions.
"src/plugins/contracts/config-footprint-guardrails.test.ts",
]);

View File

@@ -1,116 +0,0 @@
// Custom command config helpers normalize command configuration records.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
/** Raw custom slash-command entry from config. */
type CustomCommandInput = {
command?: string | null;
description?: string | null;
};
/** Validation issue for one configured custom command. */
type CustomCommandIssue = {
index: number;
field: "command" | "description";
message: string;
};
/** Command validation policy for one command family. */
type CustomCommandConfig = {
label: string;
pattern: RegExp;
patternDescription: string;
prefix?: string;
};
const DEFAULT_PREFIX = "/";
/** Normalize a slash command name to the internal lowercase underscore form. */
export function normalizeSlashCommandName(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const withoutSlash = trimmed.startsWith(DEFAULT_PREFIX) ? trimmed.slice(1) : trimmed;
return normalizeLowercaseStringOrEmpty(withoutSlash).replace(/-/g, "_");
}
/** Normalize command descriptions without changing user-authored wording. */
export function normalizeCommandDescription(value: string): string {
return value.trim();
}
/** Validate and normalize custom command config entries. */
export function resolveCustomCommands(params: {
commands?: CustomCommandInput[] | null;
reservedCommands?: Set<string>;
checkReserved?: boolean;
checkDuplicates?: boolean;
config: CustomCommandConfig;
}): {
commands: Array<{ command: string; description: string }>;
issues: CustomCommandIssue[];
} {
const entries = Array.isArray(params.commands) ? params.commands : [];
const reserved = params.reservedCommands ?? new Set<string>();
const checkReserved = params.checkReserved !== false;
const checkDuplicates = params.checkDuplicates !== false;
const seen = new Set<string>();
const resolved: Array<{ command: string; description: string }> = [];
const issues: CustomCommandIssue[] = [];
const label = params.config.label;
const prefix = params.config.prefix ?? DEFAULT_PREFIX;
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
// Accumulate issues instead of throwing so config UIs and CLIs can present
// all invalid commands in one pass.
const normalized = normalizeSlashCommandName(entry?.command ?? "");
if (!normalized) {
issues.push({
index,
field: "command",
message: `${label} custom command is missing a command name.`,
});
continue;
}
if (!params.config.pattern.test(normalized)) {
issues.push({
index,
field: "command",
message: `${label} custom command "${prefix}${normalized}" is invalid (${params.config.patternDescription}).`,
});
continue;
}
if (checkReserved && reserved.has(normalized)) {
issues.push({
index,
field: "command",
message: `${label} custom command "${prefix}${normalized}" conflicts with a native command.`,
});
continue;
}
if (checkDuplicates && seen.has(normalized)) {
issues.push({
index,
field: "command",
message: `${label} custom command "${prefix}${normalized}" is duplicated.`,
});
continue;
}
const description = normalizeCommandDescription(entry?.description ?? "");
if (!description) {
issues.push({
index,
field: "description",
message: `${label} custom command "${prefix}${normalized}" is missing a description.`,
});
continue;
}
if (checkDuplicates) {
seen.add(normalized);
}
resolved.push({ command: normalized, description });
}
return { commands: resolved, issues };
}