mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 06:51:35 +00:00
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:
committed by
GitHub
parent
099d6351b3
commit
ebec398543
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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
6
pnpm-lock.yaml
generated
@@ -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:*
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
21
src/plugin-sdk/bundled-channel-config-schema.test.ts
Normal file
21
src/plugin-sdk/bundled-channel-config-schema.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user