1523 lines
56 KiB
TypeScript
1523 lines
56 KiB
TypeScript
import { z } from "zod";
|
|
import { isSafeScpRemoteHost } from "../infra/scp-host.js";
|
|
import { isValidInboundPathRootPattern } from "../media/inbound-path-policy.js";
|
|
import {
|
|
resolveDiscordPreviewStreamMode,
|
|
resolveSlackNativeStreaming,
|
|
resolveSlackStreamingMode,
|
|
resolveTelegramPreviewStreamMode,
|
|
} from "./discord-preview-streaming.js";
|
|
import {
|
|
normalizeTelegramCommandDescription,
|
|
normalizeTelegramCommandName,
|
|
resolveTelegramCustomCommands,
|
|
} from "./telegram-custom-commands.js";
|
|
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
|
|
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
|
import {
|
|
BlockStreamingChunkSchema,
|
|
BlockStreamingCoalesceSchema,
|
|
DmConfigSchema,
|
|
DmPolicySchema,
|
|
ExecutableTokenSchema,
|
|
GroupPolicySchema,
|
|
HexColorSchema,
|
|
MarkdownConfigSchema,
|
|
MSTeamsReplyStyleSchema,
|
|
ProviderCommandsSchema,
|
|
SecretRefSchema,
|
|
SecretInputSchema,
|
|
ReplyToModeSchema,
|
|
RetryConfigSchema,
|
|
TtsConfigSchema,
|
|
requireAllowlistAllowFrom,
|
|
requireOpenAllowFrom,
|
|
} from "./zod-schema.core.js";
|
|
import {
|
|
validateSlackSigningSecretRequirements,
|
|
validateTelegramWebhookSecretRequirements,
|
|
} from "./zod-schema.secret-input-validation.js";
|
|
import { sensitive } from "./zod-schema.sensitive.js";
|
|
|
|
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
|
|
|
|
const DiscordIdSchema = z
|
|
.union([z.string(), z.number()])
|
|
.refine((value) => typeof value === "string", {
|
|
message: "Discord IDs must be strings (wrap numeric IDs in quotes).",
|
|
});
|
|
const DiscordIdListSchema = z.array(DiscordIdSchema);
|
|
|
|
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
|
|
const TelegramIdListSchema = z.array(z.union([z.string(), z.number()]));
|
|
|
|
const TelegramCapabilitiesSchema = z.union([
|
|
z.array(z.string()),
|
|
z
|
|
.object({
|
|
inlineButtons: TelegramInlineButtonsScopeSchema.optional(),
|
|
})
|
|
.strict(),
|
|
]);
|
|
const SlackCapabilitiesSchema = z.union([
|
|
z.array(z.string()),
|
|
z
|
|
.object({
|
|
interactiveReplies: z.boolean().optional(),
|
|
})
|
|
.strict(),
|
|
]);
|
|
|
|
export const TelegramTopicSchema = z
|
|
.object({
|
|
requireMention: 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(),
|
|
})
|
|
.strict();
|
|
|
|
export const TelegramGroupSchema = z
|
|
.object({
|
|
requireMention: z.boolean().optional(),
|
|
disableAudioPreflight: z.boolean().optional(),
|
|
groupPolicy: GroupPolicySchema.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(),
|
|
})
|
|
.strict();
|
|
|
|
export 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(),
|
|
requireTopic: z.boolean().optional(),
|
|
})
|
|
.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,
|
|
});
|
|
}
|
|
};
|
|
|
|
function normalizeTelegramStreamingConfig(value: { streaming?: unknown; streamMode?: unknown }) {
|
|
value.streaming = resolveTelegramPreviewStreamMode(value);
|
|
delete value.streamMode;
|
|
}
|
|
|
|
function normalizeDiscordStreamingConfig(value: { streaming?: unknown; streamMode?: unknown }) {
|
|
value.streaming = resolveDiscordPreviewStreamMode(value);
|
|
delete value.streamMode;
|
|
}
|
|
|
|
function normalizeSlackStreamingConfig(value: {
|
|
streaming?: unknown;
|
|
nativeStreaming?: unknown;
|
|
streamMode?: unknown;
|
|
}) {
|
|
value.nativeStreaming = resolveSlackNativeStreaming(value);
|
|
value.streaming = resolveSlackStreamingMode(value);
|
|
delete value.streamMode;
|
|
}
|
|
|
|
export const TelegramAccountSchemaBase = z
|
|
.object({
|
|
name: z.string().optional(),
|
|
capabilities: TelegramCapabilitiesSchema.optional(),
|
|
execApprovals: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
approvers: TelegramIdListSchema.optional(),
|
|
agentFilter: z.array(z.string()).optional(),
|
|
sessionFilter: z.array(z.string()).optional(),
|
|
target: z.enum(["dm", "channel", "both"]).optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
markdown: MarkdownConfigSchema,
|
|
enabled: z.boolean().optional(),
|
|
commands: ProviderCommandsSchema,
|
|
customCommands: z.array(TelegramCustomCommandSchema).optional(),
|
|
configWrites: z.boolean().optional(),
|
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
botToken: SecretInputSchema.optional().register(sensitive),
|
|
tokenFile: z.string().optional(),
|
|
replyToMode: ReplyToModeSchema.optional(),
|
|
groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(),
|
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
defaultTo: z.union([z.string(), z.number()]).optional(),
|
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
historyLimit: z.number().int().min(0).optional(),
|
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
direct: z.record(z.string(), TelegramDirectSchema.optional()).optional(),
|
|
textChunkLimit: z.number().int().positive().optional(),
|
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
streaming: z.union([z.boolean(), z.enum(["off", "partial", "block", "progress"])]).optional(),
|
|
blockStreaming: z.boolean().optional(),
|
|
draftChunk: BlockStreamingChunkSchema.optional(),
|
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
// Legacy key kept for automatic migration to `streaming`.
|
|
streamMode: z.enum(["off", "partial", "block"]).optional(),
|
|
mediaMaxMb: z.number().positive().optional(),
|
|
timeoutSeconds: z.number().int().positive().optional(),
|
|
retry: RetryConfigSchema,
|
|
network: z
|
|
.object({
|
|
autoSelectFamily: z.boolean().optional(),
|
|
dnsResultOrder: z.enum(["ipv4first", "verbatim"]).optional(),
|
|
})
|
|
.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(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
threadBindings: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
idleHours: z.number().nonnegative().optional(),
|
|
maxAgeHours: z.number().nonnegative().optional(),
|
|
spawnSubagentSessions: z.boolean().optional(),
|
|
spawnAcpSessions: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
reactionNotifications: z.enum(["off", "own", "all"]).optional(),
|
|
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
linkPreview: z.boolean().optional(),
|
|
responsePrefix: z.string().optional(),
|
|
ackReaction: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => {
|
|
normalizeTelegramStreamingConfig(value);
|
|
// 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) => {
|
|
normalizeTelegramStreamingConfig(value);
|
|
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 DiscordDmSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
policy: DmPolicySchema.optional(),
|
|
allowFrom: DiscordIdListSchema.optional(),
|
|
groupEnabled: z.boolean().optional(),
|
|
groupChannels: DiscordIdListSchema.optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const DiscordGuildChannelSchema = z
|
|
.object({
|
|
allow: z.boolean().optional(),
|
|
requireMention: z.boolean().optional(),
|
|
ignoreOtherMentions: z.boolean().optional(),
|
|
tools: ToolPolicySchema,
|
|
toolsBySender: ToolPolicyBySenderSchema,
|
|
skills: z.array(z.string()).optional(),
|
|
enabled: z.boolean().optional(),
|
|
users: DiscordIdListSchema.optional(),
|
|
roles: DiscordIdListSchema.optional(),
|
|
systemPrompt: z.string().optional(),
|
|
includeThreadStarter: z.boolean().optional(),
|
|
autoThread: z.boolean().optional(),
|
|
/** Archive duration for auto-created threads in minutes. Discord supports 60, 1440 (1 day), 4320 (3 days), 10080 (1 week). Default: 60. */
|
|
autoArchiveDuration: z
|
|
.union([
|
|
z.enum(["60", "1440", "4320", "10080"]),
|
|
z.literal(60),
|
|
z.literal(1440),
|
|
z.literal(4320),
|
|
z.literal(10080),
|
|
])
|
|
.optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const DiscordGuildSchema = z
|
|
.object({
|
|
slug: z.string().optional(),
|
|
requireMention: z.boolean().optional(),
|
|
ignoreOtherMentions: z.boolean().optional(),
|
|
tools: ToolPolicySchema,
|
|
toolsBySender: ToolPolicyBySenderSchema,
|
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
|
users: DiscordIdListSchema.optional(),
|
|
roles: DiscordIdListSchema.optional(),
|
|
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
|
|
})
|
|
.strict();
|
|
|
|
const DiscordUiSchema = z
|
|
.object({
|
|
components: z
|
|
.object({
|
|
accentColor: HexColorSchema.optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
})
|
|
.strict()
|
|
.optional();
|
|
|
|
const DiscordVoiceAutoJoinSchema = z
|
|
.object({
|
|
guildId: z.string().min(1),
|
|
channelId: z.string().min(1),
|
|
})
|
|
.strict();
|
|
|
|
const DiscordVoiceSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(),
|
|
daveEncryption: z.boolean().optional(),
|
|
decryptionFailureTolerance: z.number().int().min(0).optional(),
|
|
tts: TtsConfigSchema.optional(),
|
|
})
|
|
.strict()
|
|
.optional();
|
|
|
|
export const DiscordAccountSchema = z
|
|
.object({
|
|
name: z.string().optional(),
|
|
capabilities: z.array(z.string()).optional(),
|
|
markdown: MarkdownConfigSchema,
|
|
enabled: z.boolean().optional(),
|
|
commands: ProviderCommandsSchema,
|
|
configWrites: z.boolean().optional(),
|
|
token: SecretInputSchema.optional().register(sensitive),
|
|
proxy: z.string().optional(),
|
|
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
|
|
dangerouslyAllowNameMatching: z.boolean().optional(),
|
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
historyLimit: z.number().int().min(0).optional(),
|
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
textChunkLimit: z.number().int().positive().optional(),
|
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
blockStreaming: z.boolean().optional(),
|
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
// Canonical streaming mode. Legacy aliases (`streamMode`, boolean `streaming`) are auto-mapped.
|
|
streaming: z.union([z.boolean(), z.enum(["off", "partial", "block", "progress"])]).optional(),
|
|
streamMode: z.enum(["partial", "block", "off"]).optional(),
|
|
draftChunk: BlockStreamingChunkSchema.optional(),
|
|
maxLinesPerMessage: z.number().int().positive().optional(),
|
|
mediaMaxMb: z.number().positive().optional(),
|
|
retry: RetryConfigSchema,
|
|
actions: z
|
|
.object({
|
|
reactions: z.boolean().optional(),
|
|
stickers: z.boolean().optional(),
|
|
emojiUploads: z.boolean().optional(),
|
|
stickerUploads: z.boolean().optional(),
|
|
polls: z.boolean().optional(),
|
|
permissions: z.boolean().optional(),
|
|
messages: z.boolean().optional(),
|
|
threads: z.boolean().optional(),
|
|
pins: z.boolean().optional(),
|
|
search: z.boolean().optional(),
|
|
memberInfo: z.boolean().optional(),
|
|
roleInfo: z.boolean().optional(),
|
|
roles: z.boolean().optional(),
|
|
channelInfo: z.boolean().optional(),
|
|
voiceStatus: z.boolean().optional(),
|
|
events: z.boolean().optional(),
|
|
moderation: z.boolean().optional(),
|
|
channels: z.boolean().optional(),
|
|
presence: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
replyToMode: ReplyToModeSchema.optional(),
|
|
// Aliases for channels.discord.dm.policy / channels.discord.dm.allowFrom. Prefer these for
|
|
// inheritance in multi-account setups (shallow merge works; nested dm object doesn't).
|
|
dmPolicy: DmPolicySchema.optional(),
|
|
allowFrom: DiscordIdListSchema.optional(),
|
|
defaultTo: z.string().optional(),
|
|
dm: DiscordDmSchema.optional(),
|
|
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
|
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
execApprovals: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
approvers: DiscordIdListSchema.optional(),
|
|
agentFilter: z.array(z.string()).optional(),
|
|
sessionFilter: z.array(z.string()).optional(),
|
|
cleanupAfterResolve: z.boolean().optional(),
|
|
target: z.enum(["dm", "channel", "both"]).optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
agentComponents: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
ui: DiscordUiSchema,
|
|
slashCommand: z
|
|
.object({
|
|
ephemeral: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
threadBindings: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
idleHours: z.number().nonnegative().optional(),
|
|
maxAgeHours: z.number().nonnegative().optional(),
|
|
spawnSubagentSessions: z.boolean().optional(),
|
|
spawnAcpSessions: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
intents: z
|
|
.object({
|
|
presence: z.boolean().optional(),
|
|
guildMembers: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
voice: DiscordVoiceSchema,
|
|
pluralkit: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
token: SecretInputSchema.optional().register(sensitive),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
responsePrefix: z.string().optional(),
|
|
ackReaction: z.string().optional(),
|
|
ackReactionScope: z
|
|
.enum(["group-mentions", "group-all", "direct", "all", "off", "none"])
|
|
.optional(),
|
|
activity: z.string().optional(),
|
|
status: z.enum(["online", "dnd", "idle", "invisible"]).optional(),
|
|
autoPresence: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
intervalMs: z.number().int().positive().optional(),
|
|
minUpdateIntervalMs: z.number().int().positive().optional(),
|
|
healthyText: z.string().optional(),
|
|
degradedText: z.string().optional(),
|
|
exhaustedText: z.string().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
activityType: z
|
|
.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)])
|
|
.optional(),
|
|
activityUrl: z.string().url().optional(),
|
|
inboundWorker: z
|
|
.object({
|
|
runTimeoutMs: z.number().int().nonnegative().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
eventQueue: z
|
|
.object({
|
|
listenerTimeout: z.number().int().positive().optional(),
|
|
maxQueueSize: z.number().int().positive().optional(),
|
|
maxConcurrency: z.number().int().positive().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
normalizeDiscordStreamingConfig(value);
|
|
|
|
const activityText = typeof value.activity === "string" ? value.activity.trim() : "";
|
|
const hasActivity = Boolean(activityText);
|
|
const hasActivityType = value.activityType !== undefined;
|
|
const activityUrl = typeof value.activityUrl === "string" ? value.activityUrl.trim() : "";
|
|
const hasActivityUrl = Boolean(activityUrl);
|
|
|
|
if ((hasActivityType || hasActivityUrl) && !hasActivity) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "channels.discord.activity is required when activityType or activityUrl is set",
|
|
path: ["activity"],
|
|
});
|
|
}
|
|
|
|
if (value.activityType === 1 && !hasActivityUrl) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "channels.discord.activityUrl is required when activityType is 1 (Streaming)",
|
|
path: ["activityUrl"],
|
|
});
|
|
}
|
|
|
|
if (hasActivityUrl && value.activityType !== 1) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "channels.discord.activityType must be 1 (Streaming) when activityUrl is set",
|
|
path: ["activityType"],
|
|
});
|
|
}
|
|
|
|
const autoPresenceInterval = value.autoPresence?.intervalMs;
|
|
const autoPresenceMinUpdate = value.autoPresence?.minUpdateIntervalMs;
|
|
if (
|
|
typeof autoPresenceInterval === "number" &&
|
|
typeof autoPresenceMinUpdate === "number" &&
|
|
autoPresenceMinUpdate > autoPresenceInterval
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message:
|
|
"channels.discord.autoPresence.minUpdateIntervalMs must be less than or equal to channels.discord.autoPresence.intervalMs",
|
|
path: ["autoPresence", "minUpdateIntervalMs"],
|
|
});
|
|
}
|
|
|
|
// DM allowlist validation is enforced at DiscordConfigSchema so account entries
|
|
// can inherit top-level allowFrom via runtime shallow merge.
|
|
});
|
|
|
|
export const DiscordConfigSchema = DiscordAccountSchema.extend({
|
|
accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(),
|
|
defaultAccount: z.string().optional(),
|
|
}).superRefine((value, ctx) => {
|
|
const dmPolicy = value.dmPolicy ?? value.dm?.policy ?? "pairing";
|
|
const allowFrom = value.allowFrom ?? value.dm?.allowFrom;
|
|
const allowFromPath =
|
|
value.allowFrom !== undefined ? (["allowFrom"] as const) : (["dm", "allowFrom"] as const);
|
|
requireOpenAllowFrom({
|
|
policy: dmPolicy,
|
|
allowFrom,
|
|
ctx,
|
|
path: [...allowFromPath],
|
|
message:
|
|
'channels.discord.dmPolicy="open" requires channels.discord.allowFrom (or channels.discord.dm.allowFrom) to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: dmPolicy,
|
|
allowFrom,
|
|
ctx,
|
|
path: [...allowFromPath],
|
|
message:
|
|
'channels.discord.dmPolicy="allowlist" requires channels.discord.allowFrom (or channels.discord.dm.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 ?? account.dm?.policy ?? value.dmPolicy ?? value.dm?.policy ?? "pairing";
|
|
const effectiveAllowFrom =
|
|
account.allowFrom ?? account.dm?.allowFrom ?? value.allowFrom ?? value.dm?.allowFrom;
|
|
requireOpenAllowFrom({
|
|
policy: effectivePolicy,
|
|
allowFrom: effectiveAllowFrom,
|
|
ctx,
|
|
path: ["accounts", accountId, "allowFrom"],
|
|
message:
|
|
'channels.discord.accounts.*.dmPolicy="open" requires channels.discord.accounts.*.allowFrom (or channels.discord.allowFrom) to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: effectivePolicy,
|
|
allowFrom: effectiveAllowFrom,
|
|
ctx,
|
|
path: ["accounts", accountId, "allowFrom"],
|
|
message:
|
|
'channels.discord.accounts.*.dmPolicy="allowlist" requires channels.discord.accounts.*.allowFrom (or channels.discord.allowFrom) to contain at least one sender ID',
|
|
});
|
|
}
|
|
});
|
|
|
|
export const GoogleChatDmSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
policy: DmPolicySchema.optional().default("pairing"),
|
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
requireOpenAllowFrom({
|
|
policy: value.policy,
|
|
allowFrom: value.allowFrom,
|
|
ctx,
|
|
path: ["allowFrom"],
|
|
message:
|
|
'channels.googlechat.dm.policy="open" requires channels.googlechat.dm.allowFrom to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: value.policy,
|
|
allowFrom: value.allowFrom,
|
|
ctx,
|
|
path: ["allowFrom"],
|
|
message:
|
|
'channels.googlechat.dm.policy="allowlist" requires channels.googlechat.dm.allowFrom to contain at least one sender ID',
|
|
});
|
|
});
|
|
|
|
export const GoogleChatGroupSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
allow: z.boolean().optional(),
|
|
requireMention: z.boolean().optional(),
|
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
|
systemPrompt: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const GoogleChatAccountSchema = z
|
|
.object({
|
|
name: z.string().optional(),
|
|
capabilities: z.array(z.string()).optional(),
|
|
enabled: z.boolean().optional(),
|
|
configWrites: z.boolean().optional(),
|
|
allowBots: z.boolean().optional(),
|
|
dangerouslyAllowNameMatching: z.boolean().optional(),
|
|
requireMention: z.boolean().optional(),
|
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
groups: z.record(z.string(), GoogleChatGroupSchema.optional()).optional(),
|
|
defaultTo: z.string().optional(),
|
|
serviceAccount: z
|
|
.union([z.string(), z.record(z.string(), z.unknown()), SecretRefSchema])
|
|
.optional()
|
|
.register(sensitive),
|
|
serviceAccountRef: SecretRefSchema.optional().register(sensitive),
|
|
serviceAccountFile: z.string().optional(),
|
|
audienceType: z.enum(["app-url", "project-number"]).optional(),
|
|
audience: z.string().optional(),
|
|
webhookPath: z.string().optional(),
|
|
webhookUrl: z.string().optional(),
|
|
botUser: z.string().optional(),
|
|
historyLimit: z.number().int().min(0).optional(),
|
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
textChunkLimit: z.number().int().positive().optional(),
|
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
blockStreaming: z.boolean().optional(),
|
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
streamMode: z.enum(["replace", "status_final", "append"]).optional().default("replace"),
|
|
mediaMaxMb: z.number().positive().optional(),
|
|
replyToMode: ReplyToModeSchema.optional(),
|
|
actions: z
|
|
.object({
|
|
reactions: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
dm: GoogleChatDmSchema.optional(),
|
|
typingIndicator: z.enum(["none", "message", "reaction"]).optional(),
|
|
responsePrefix: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const GoogleChatConfigSchema = GoogleChatAccountSchema.extend({
|
|
accounts: z.record(z.string(), GoogleChatAccountSchema.optional()).optional(),
|
|
defaultAccount: z.string().optional(),
|
|
});
|
|
|
|
export const SlackDmSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
policy: DmPolicySchema.optional(),
|
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
groupEnabled: z.boolean().optional(),
|
|
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
|
replyToMode: ReplyToModeSchema.optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const SlackChannelSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
allow: z.boolean().optional(),
|
|
requireMention: z.boolean().optional(),
|
|
tools: ToolPolicySchema,
|
|
toolsBySender: ToolPolicyBySenderSchema,
|
|
allowBots: z.boolean().optional(),
|
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
|
skills: z.array(z.string()).optional(),
|
|
systemPrompt: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const SlackThreadSchema = z
|
|
.object({
|
|
historyScope: z.enum(["thread", "channel"]).optional(),
|
|
inheritParent: z.boolean().optional(),
|
|
initialHistoryLimit: z.number().int().min(0).optional(),
|
|
})
|
|
.strict();
|
|
|
|
const SlackReplyToModeByChatTypeSchema = z
|
|
.object({
|
|
direct: ReplyToModeSchema.optional(),
|
|
group: ReplyToModeSchema.optional(),
|
|
channel: ReplyToModeSchema.optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const SlackAccountSchema = z
|
|
.object({
|
|
name: z.string().optional(),
|
|
mode: z.enum(["socket", "http"]).optional(),
|
|
signingSecret: SecretInputSchema.optional().register(sensitive),
|
|
webhookPath: z.string().optional(),
|
|
capabilities: SlackCapabilitiesSchema.optional(),
|
|
markdown: MarkdownConfigSchema,
|
|
enabled: z.boolean().optional(),
|
|
commands: ProviderCommandsSchema,
|
|
configWrites: z.boolean().optional(),
|
|
botToken: SecretInputSchema.optional().register(sensitive),
|
|
appToken: SecretInputSchema.optional().register(sensitive),
|
|
userToken: SecretInputSchema.optional().register(sensitive),
|
|
userTokenReadOnly: z.boolean().optional().default(true),
|
|
allowBots: z.boolean().optional(),
|
|
dangerouslyAllowNameMatching: z.boolean().optional(),
|
|
requireMention: z.boolean().optional(),
|
|
groupPolicy: GroupPolicySchema.optional(),
|
|
historyLimit: z.number().int().min(0).optional(),
|
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
textChunkLimit: z.number().int().positive().optional(),
|
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
blockStreaming: z.boolean().optional(),
|
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
streaming: z.union([z.boolean(), z.enum(["off", "partial", "block", "progress"])]).optional(),
|
|
nativeStreaming: z.boolean().optional(),
|
|
streamMode: z.enum(["replace", "status_final", "append"]).optional(),
|
|
mediaMaxMb: z.number().positive().optional(),
|
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
|
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
|
replyToMode: ReplyToModeSchema.optional(),
|
|
replyToModeByChatType: SlackReplyToModeByChatTypeSchema.optional(),
|
|
thread: SlackThreadSchema.optional(),
|
|
actions: z
|
|
.object({
|
|
reactions: z.boolean().optional(),
|
|
messages: z.boolean().optional(),
|
|
pins: z.boolean().optional(),
|
|
search: z.boolean().optional(),
|
|
permissions: z.boolean().optional(),
|
|
memberInfo: z.boolean().optional(),
|
|
channelInfo: z.boolean().optional(),
|
|
emojiList: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
slashCommand: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
name: z.string().optional(),
|
|
sessionPrefix: z.string().optional(),
|
|
ephemeral: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
// Aliases for channels.slack.dm.policy / channels.slack.dm.allowFrom. Prefer these for
|
|
// inheritance in multi-account setups (shallow merge works; nested dm object doesn't).
|
|
dmPolicy: DmPolicySchema.optional(),
|
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
defaultTo: z.string().optional(),
|
|
dm: SlackDmSchema.optional(),
|
|
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
|
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
responsePrefix: z.string().optional(),
|
|
ackReaction: z.string().optional(),
|
|
typingReaction: z.string().optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value) => {
|
|
normalizeSlackStreamingConfig(value);
|
|
|
|
// DM allowlist validation is enforced at SlackConfigSchema so account entries
|
|
// can inherit top-level allowFrom via runtime shallow merge.
|
|
});
|
|
|
|
export const SlackConfigSchema = SlackAccountSchema.safeExtend({
|
|
mode: z.enum(["socket", "http"]).optional().default("socket"),
|
|
signingSecret: SecretInputSchema.optional().register(sensitive),
|
|
webhookPath: z.string().optional().default("/slack/events"),
|
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
|
|
defaultAccount: z.string().optional(),
|
|
}).superRefine((value, ctx) => {
|
|
const dmPolicy = value.dmPolicy ?? value.dm?.policy ?? "pairing";
|
|
const allowFrom = value.allowFrom ?? value.dm?.allowFrom;
|
|
const allowFromPath =
|
|
value.allowFrom !== undefined ? (["allowFrom"] as const) : (["dm", "allowFrom"] as const);
|
|
requireOpenAllowFrom({
|
|
policy: dmPolicy,
|
|
allowFrom,
|
|
ctx,
|
|
path: [...allowFromPath],
|
|
message:
|
|
'channels.slack.dmPolicy="open" requires channels.slack.allowFrom (or channels.slack.dm.allowFrom) to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: dmPolicy,
|
|
allowFrom,
|
|
ctx,
|
|
path: [...allowFromPath],
|
|
message:
|
|
'channels.slack.dmPolicy="allowlist" requires channels.slack.allowFrom (or channels.slack.dm.allowFrom) to contain at least one sender ID',
|
|
});
|
|
|
|
const baseMode = value.mode ?? "socket";
|
|
if (!value.accounts) {
|
|
validateSlackSigningSecretRequirements(value, ctx);
|
|
return;
|
|
}
|
|
for (const [accountId, account] of Object.entries(value.accounts)) {
|
|
if (!account) {
|
|
continue;
|
|
}
|
|
if (account.enabled === false) {
|
|
continue;
|
|
}
|
|
const accountMode = account.mode ?? baseMode;
|
|
const effectivePolicy =
|
|
account.dmPolicy ?? account.dm?.policy ?? value.dmPolicy ?? value.dm?.policy ?? "pairing";
|
|
const effectiveAllowFrom =
|
|
account.allowFrom ?? account.dm?.allowFrom ?? value.allowFrom ?? value.dm?.allowFrom;
|
|
requireOpenAllowFrom({
|
|
policy: effectivePolicy,
|
|
allowFrom: effectiveAllowFrom,
|
|
ctx,
|
|
path: ["accounts", accountId, "allowFrom"],
|
|
message:
|
|
'channels.slack.accounts.*.dmPolicy="open" requires channels.slack.accounts.*.allowFrom (or channels.slack.allowFrom) to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: effectivePolicy,
|
|
allowFrom: effectiveAllowFrom,
|
|
ctx,
|
|
path: ["accounts", accountId, "allowFrom"],
|
|
message:
|
|
'channels.slack.accounts.*.dmPolicy="allowlist" requires channels.slack.accounts.*.allowFrom (or channels.slack.allowFrom) to contain at least one sender ID',
|
|
});
|
|
if (accountMode !== "http") {
|
|
continue;
|
|
}
|
|
}
|
|
validateSlackSigningSecretRequirements(value, ctx);
|
|
});
|
|
|
|
const SignalGroupEntrySchema = z
|
|
.object({
|
|
requireMention: z.boolean().optional(),
|
|
tools: ToolPolicySchema,
|
|
toolsBySender: ToolPolicyBySenderSchema,
|
|
})
|
|
.strict();
|
|
|
|
const SignalGroupsSchema = z.record(z.string(), SignalGroupEntrySchema.optional()).optional();
|
|
|
|
export const SignalAccountSchemaBase = z
|
|
.object({
|
|
name: z.string().optional(),
|
|
capabilities: z.array(z.string()).optional(),
|
|
markdown: MarkdownConfigSchema,
|
|
enabled: z.boolean().optional(),
|
|
configWrites: z.boolean().optional(),
|
|
account: z.string().optional(),
|
|
accountUuid: z.string().optional(),
|
|
httpUrl: z.string().optional(),
|
|
httpHost: z.string().optional(),
|
|
httpPort: z.number().int().positive().optional(),
|
|
cliPath: ExecutableTokenSchema.optional(),
|
|
autoStart: z.boolean().optional(),
|
|
startupTimeoutMs: z.number().int().min(1000).max(120000).optional(),
|
|
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
|
|
ignoreAttachments: z.boolean().optional(),
|
|
ignoreStories: z.boolean().optional(),
|
|
sendReadReceipts: z.boolean().optional(),
|
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
defaultTo: z.string().optional(),
|
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
groups: SignalGroupsSchema,
|
|
historyLimit: z.number().int().min(0).optional(),
|
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
textChunkLimit: z.number().int().positive().optional(),
|
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
blockStreaming: z.boolean().optional(),
|
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
mediaMaxMb: z.number().int().positive().optional(),
|
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
|
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
|
actions: z
|
|
.object({
|
|
reactions: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
responsePrefix: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
// Account-level schemas skip allowFrom validation because accounts inherit
|
|
// allowFrom from the parent channel config at runtime.
|
|
// Validation is enforced at the top-level SignalConfigSchema instead.
|
|
export const SignalAccountSchema = SignalAccountSchemaBase;
|
|
|
|
export const SignalConfigSchema = SignalAccountSchemaBase.extend({
|
|
accounts: z.record(z.string(), SignalAccountSchema.optional()).optional(),
|
|
defaultAccount: z.string().optional(),
|
|
}).superRefine((value, ctx) => {
|
|
requireOpenAllowFrom({
|
|
policy: value.dmPolicy,
|
|
allowFrom: value.allowFrom,
|
|
ctx,
|
|
path: ["allowFrom"],
|
|
message: 'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: value.dmPolicy,
|
|
allowFrom: value.allowFrom,
|
|
ctx,
|
|
path: ["allowFrom"],
|
|
message:
|
|
'channels.signal.dmPolicy="allowlist" requires channels.signal.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.signal.accounts.*.dmPolicy="open" requires channels.signal.accounts.*.allowFrom (or channels.signal.allowFrom) to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: effectivePolicy,
|
|
allowFrom: effectiveAllowFrom,
|
|
ctx,
|
|
path: ["accounts", accountId, "allowFrom"],
|
|
message:
|
|
'channels.signal.accounts.*.dmPolicy="allowlist" requires channels.signal.accounts.*.allowFrom (or channels.signal.allowFrom) to contain at least one sender ID',
|
|
});
|
|
}
|
|
});
|
|
|
|
export const IrcGroupSchema = z
|
|
.object({
|
|
requireMention: z.boolean().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(),
|
|
})
|
|
.strict();
|
|
|
|
export const IrcNickServSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
service: z.string().optional(),
|
|
password: SecretInputSchema.optional().register(sensitive),
|
|
passwordFile: z.string().optional(),
|
|
register: z.boolean().optional(),
|
|
registerEmail: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const IrcAccountSchemaBase = z
|
|
.object({
|
|
name: z.string().optional(),
|
|
capabilities: z.array(z.string()).optional(),
|
|
markdown: MarkdownConfigSchema,
|
|
enabled: z.boolean().optional(),
|
|
configWrites: z.boolean().optional(),
|
|
host: z.string().optional(),
|
|
port: z.number().int().min(1).max(65535).optional(),
|
|
tls: z.boolean().optional(),
|
|
nick: z.string().optional(),
|
|
username: z.string().optional(),
|
|
realname: z.string().optional(),
|
|
password: SecretInputSchema.optional().register(sensitive),
|
|
passwordFile: z.string().optional(),
|
|
nickserv: IrcNickServSchema.optional(),
|
|
channels: z.array(z.string()).optional(),
|
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
defaultTo: z.string().optional(),
|
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
groups: z.record(z.string(), IrcGroupSchema.optional()).optional(),
|
|
mentionPatterns: z.array(z.string()).optional(),
|
|
historyLimit: z.number().int().min(0).optional(),
|
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
textChunkLimit: z.number().int().positive().optional(),
|
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
blockStreaming: z.boolean().optional(),
|
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
mediaMaxMb: z.number().positive().optional(),
|
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
responsePrefix: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
type IrcBaseConfig = z.infer<typeof IrcAccountSchemaBase>;
|
|
|
|
function refineIrcAllowFromAndNickserv(value: IrcBaseConfig, ctx: z.RefinementCtx): void {
|
|
requireOpenAllowFrom({
|
|
policy: value.dmPolicy,
|
|
allowFrom: value.allowFrom,
|
|
ctx,
|
|
path: ["allowFrom"],
|
|
message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: value.dmPolicy,
|
|
allowFrom: value.allowFrom,
|
|
ctx,
|
|
path: ["allowFrom"],
|
|
message:
|
|
'channels.irc.dmPolicy="allowlist" requires channels.irc.allowFrom to contain at least one sender ID',
|
|
});
|
|
if (value.nickserv?.register && !value.nickserv.registerEmail?.trim()) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["nickserv", "registerEmail"],
|
|
message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Account-level schemas skip allowFrom validation because accounts inherit
|
|
// allowFrom from the parent channel config at runtime.
|
|
// Validation is enforced at the top-level IrcConfigSchema instead.
|
|
export const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => {
|
|
// Only validate nickserv at account level, not allowFrom (inherited from parent).
|
|
if (value.nickserv?.register && !value.nickserv.registerEmail?.trim()) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["nickserv", "registerEmail"],
|
|
message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail",
|
|
});
|
|
}
|
|
});
|
|
|
|
export const IrcConfigSchema = IrcAccountSchemaBase.extend({
|
|
accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(),
|
|
defaultAccount: z.string().optional(),
|
|
}).superRefine((value, ctx) => {
|
|
refineIrcAllowFromAndNickserv(value, ctx);
|
|
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.irc.accounts.*.dmPolicy="open" requires channels.irc.accounts.*.allowFrom (or channels.irc.allowFrom) to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: effectivePolicy,
|
|
allowFrom: effectiveAllowFrom,
|
|
ctx,
|
|
path: ["accounts", accountId, "allowFrom"],
|
|
message:
|
|
'channels.irc.accounts.*.dmPolicy="allowlist" requires channels.irc.accounts.*.allowFrom (or channels.irc.allowFrom) to contain at least one sender ID',
|
|
});
|
|
}
|
|
});
|
|
|
|
export const IMessageAccountSchemaBase = z
|
|
.object({
|
|
name: z.string().optional(),
|
|
capabilities: z.array(z.string()).optional(),
|
|
markdown: MarkdownConfigSchema,
|
|
enabled: z.boolean().optional(),
|
|
configWrites: z.boolean().optional(),
|
|
cliPath: ExecutableTokenSchema.optional(),
|
|
dbPath: z.string().optional(),
|
|
remoteHost: z
|
|
.string()
|
|
.refine(isSafeScpRemoteHost, "expected SSH host or user@host (no spaces/options)")
|
|
.optional(),
|
|
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
|
|
region: z.string().optional(),
|
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
defaultTo: z.string().optional(),
|
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
historyLimit: z.number().int().min(0).optional(),
|
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
dms: z.record(z.string(), DmConfigSchema.optional()).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(),
|
|
mediaMaxMb: z.number().int().positive().optional(),
|
|
textChunkLimit: z.number().int().positive().optional(),
|
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
blockStreaming: z.boolean().optional(),
|
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
groups: z
|
|
.record(
|
|
z.string(),
|
|
z
|
|
.object({
|
|
requireMention: z.boolean().optional(),
|
|
tools: ToolPolicySchema,
|
|
toolsBySender: ToolPolicyBySenderSchema,
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
)
|
|
.optional(),
|
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
responsePrefix: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
// Account-level schemas skip allowFrom validation because accounts inherit
|
|
// allowFrom from the parent channel config at runtime.
|
|
// Validation is enforced at the top-level IMessageConfigSchema instead.
|
|
export const IMessageAccountSchema = IMessageAccountSchemaBase;
|
|
|
|
export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
|
|
accounts: z.record(z.string(), IMessageAccountSchema.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',
|
|
});
|
|
}
|
|
});
|
|
|
|
const BlueBubblesAllowFromEntry = z.union([z.string(), z.number()]);
|
|
|
|
const BlueBubblesActionSchema = 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(),
|
|
})
|
|
.strict()
|
|
.optional();
|
|
|
|
const BlueBubblesGroupConfigSchema = z
|
|
.object({
|
|
requireMention: z.boolean().optional(),
|
|
tools: ToolPolicySchema,
|
|
toolsBySender: ToolPolicyBySenderSchema,
|
|
})
|
|
.strict();
|
|
|
|
export const BlueBubblesAccountSchemaBase = z
|
|
.object({
|
|
name: z.string().optional(),
|
|
capabilities: z.array(z.string()).optional(),
|
|
markdown: MarkdownConfigSchema,
|
|
configWrites: z.boolean().optional(),
|
|
enabled: z.boolean().optional(),
|
|
serverUrl: z.string().optional(),
|
|
password: SecretInputSchema.optional().register(sensitive),
|
|
webhookPath: z.string().optional(),
|
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
allowFrom: z.array(BlueBubblesAllowFromEntry).optional(),
|
|
groupAllowFrom: z.array(BlueBubblesAllowFromEntry).optional(),
|
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
historyLimit: z.number().int().min(0).optional(),
|
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
textChunkLimit: z.number().int().positive().optional(),
|
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
mediaMaxMb: z.number().int().positive().optional(),
|
|
mediaLocalRoots: z.array(z.string()).optional(),
|
|
sendReadReceipts: z.boolean().optional(),
|
|
blockStreaming: z.boolean().optional(),
|
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
groups: z.record(z.string(), BlueBubblesGroupConfigSchema.optional()).optional(),
|
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
responsePrefix: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
// Account-level schemas skip allowFrom validation because accounts inherit
|
|
// allowFrom from the parent channel config at runtime.
|
|
// Validation is enforced at the top-level BlueBubblesConfigSchema instead.
|
|
export const BlueBubblesAccountSchema = BlueBubblesAccountSchemaBase;
|
|
|
|
export const BlueBubblesConfigSchema = BlueBubblesAccountSchemaBase.extend({
|
|
accounts: z.record(z.string(), BlueBubblesAccountSchema.optional()).optional(),
|
|
defaultAccount: z.string().optional(),
|
|
actions: BlueBubblesActionSchema,
|
|
}).superRefine((value, ctx) => {
|
|
requireOpenAllowFrom({
|
|
policy: value.dmPolicy,
|
|
allowFrom: value.allowFrom,
|
|
ctx,
|
|
path: ["allowFrom"],
|
|
message:
|
|
'channels.bluebubbles.dmPolicy="open" requires channels.bluebubbles.allowFrom to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: value.dmPolicy,
|
|
allowFrom: value.allowFrom,
|
|
ctx,
|
|
path: ["allowFrom"],
|
|
message:
|
|
'channels.bluebubbles.dmPolicy="allowlist" requires channels.bluebubbles.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.bluebubbles.accounts.*.dmPolicy="open" requires channels.bluebubbles.accounts.*.allowFrom (or channels.bluebubbles.allowFrom) to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: effectivePolicy,
|
|
allowFrom: effectiveAllowFrom,
|
|
ctx,
|
|
path: ["accounts", accountId, "allowFrom"],
|
|
message:
|
|
'channels.bluebubbles.accounts.*.dmPolicy="allowlist" requires channels.bluebubbles.accounts.*.allowFrom (or channels.bluebubbles.allowFrom) to contain at least one sender ID',
|
|
});
|
|
}
|
|
});
|
|
|
|
export const MSTeamsChannelSchema = z
|
|
.object({
|
|
requireMention: z.boolean().optional(),
|
|
tools: ToolPolicySchema,
|
|
toolsBySender: ToolPolicyBySenderSchema,
|
|
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const MSTeamsTeamSchema = z
|
|
.object({
|
|
requireMention: z.boolean().optional(),
|
|
tools: ToolPolicySchema,
|
|
toolsBySender: ToolPolicyBySenderSchema,
|
|
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
|
channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const MSTeamsConfigSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
capabilities: z.array(z.string()).optional(),
|
|
dangerouslyAllowNameMatching: z.boolean().optional(),
|
|
markdown: MarkdownConfigSchema,
|
|
configWrites: z.boolean().optional(),
|
|
appId: z.string().optional(),
|
|
appPassword: SecretInputSchema.optional().register(sensitive),
|
|
tenantId: z.string().optional(),
|
|
webhook: z
|
|
.object({
|
|
port: z.number().int().positive().optional(),
|
|
path: z.string().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
allowFrom: z.array(z.string()).optional(),
|
|
defaultTo: z.string().optional(),
|
|
groupAllowFrom: z.array(z.string()).optional(),
|
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
textChunkLimit: z.number().int().positive().optional(),
|
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
mediaAllowHosts: z.array(z.string()).optional(),
|
|
mediaAuthAllowHosts: z.array(z.string()).optional(),
|
|
requireMention: z.boolean().optional(),
|
|
historyLimit: z.number().int().min(0).optional(),
|
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
|
teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(),
|
|
/** Max media size in MB (default: 100MB for OneDrive upload support). */
|
|
mediaMaxMb: z.number().positive().optional(),
|
|
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */
|
|
sharePointSiteId: z.string().optional(),
|
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
responsePrefix: z.string().optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
requireOpenAllowFrom({
|
|
policy: value.dmPolicy,
|
|
allowFrom: value.allowFrom,
|
|
ctx,
|
|
path: ["allowFrom"],
|
|
message:
|
|
'channels.msteams.dmPolicy="open" requires channels.msteams.allowFrom to include "*"',
|
|
});
|
|
requireAllowlistAllowFrom({
|
|
policy: value.dmPolicy,
|
|
allowFrom: value.allowFrom,
|
|
ctx,
|
|
path: ["allowFrom"],
|
|
message:
|
|
'channels.msteams.dmPolicy="allowlist" requires channels.msteams.allowFrom to contain at least one sender ID',
|
|
});
|
|
});
|