/** * Loading and normalizing the `wechat` section of the shared `bytefly.json`. * * The effective config is the global section overridden by the project * section. Unknown keys are ignored and every value is clamped or cleaned. * Connection state is not stored here; the stored account controls the bridge * lifecycle. */ import { clampInteger, cleanString } from "../../lib/json.ts"; import { loadConfigSection } from "../../lib/config.ts"; export interface WechatConfig { /** Turns allowed in a rolling hour; 0 disables the cap. */ maxTurnsPerHour: number; /** Replies longer than this are split into multiple messages. */ maxReplyChars: number; /** Observability identifier sent as `base_info.bot_agent`. */ botAgent: string; /** Plugin version sent as `base_info.channel_version`. */ channelVersion: string; /** Base URL for CDN media downloads and uploads. */ cdnBaseUrl: string; /** Media larger than this is skipped inbound and refused outbound. */ maxMediaBytes: number; /** Inbound files older than this many days are pruned; 0 keeps every one. */ mediaRetentionDays: number; /** Inbound text longer than this is truncated before it reaches the model. */ maxInboundChars: number; /** Send a typing indicator while an inbound turn is being generated. */ typingIndicator: boolean; /** * Ask the server for this long-poll hold, in milliseconds. Unset means "use the * server's own", which is what every release before this option did; the * field is a protocol experiment, so it is opt-in. */ pollHoldMs?: number; } export const DEFAULT_CONFIG: WechatConfig = { maxTurnsPerHour: 0, maxReplyChars: 4000, botAgent: "pi-wechat", channelVersion: "0.1.0", cdnBaseUrl: "https://novac2c.cdn.weixin.qq.com/c2c", maxMediaBytes: 20 * 1024 * 1024, mediaRetentionDays: 7, maxInboundChars: 8000, typingIndicator: true, }; /** A cleaned, slash-free base URL. */ function toBaseUrl(value: unknown, fallback: string): string { return cleanString(value, fallback).replace(/\/+$/, ""); } /** Validate/clamp a raw config object over a running base. */ export function normalizeConfig(raw: Record | undefined, base: WechatConfig): WechatConfig { const config: WechatConfig = { ...base }; if (!raw) return config; if (raw.maxTurnsPerHour !== undefined) { config.maxTurnsPerHour = clampInteger(raw.maxTurnsPerHour, base.maxTurnsPerHour, 0, 500); } if (raw.maxReplyChars !== undefined) { config.maxReplyChars = clampInteger(raw.maxReplyChars, base.maxReplyChars, 1, 20000); } if (raw.botAgent !== undefined) { config.botAgent = cleanString(raw.botAgent, base.botAgent).slice(0, 256); } if (raw.channelVersion !== undefined) { config.channelVersion = cleanString(raw.channelVersion, base.channelVersion); } if (raw.cdnBaseUrl !== undefined) { config.cdnBaseUrl = toBaseUrl(raw.cdnBaseUrl, base.cdnBaseUrl); } if (raw.maxMediaBytes !== undefined) { config.maxMediaBytes = clampInteger(raw.maxMediaBytes, base.maxMediaBytes, 1024, 100 * 1024 * 1024); } if (raw.mediaRetentionDays !== undefined) { config.mediaRetentionDays = clampInteger(raw.mediaRetentionDays, base.mediaRetentionDays, 0, 3650); } if (raw.maxInboundChars !== undefined) { config.maxInboundChars = clampInteger(raw.maxInboundChars, base.maxInboundChars, 1000, 100_000); } if (typeof raw.typingIndicator === "boolean") { config.typingIndicator = raw.typingIndicator; } if (raw.pollHoldMs !== undefined) { // A malformed value keeps an inherited hold, as every sibling field does; only a // numeric `0` clears it. const hold = clampInteger(raw.pollHoldMs, base.pollHoldMs ?? 0, 0, 120_000); config.pollHoldMs = hold >= 1000 ? hold : undefined; } return config; } /** The effective config from the shared `wechat` section. */ export function loadWechatConfig(cwd: string): WechatConfig { return loadConfigSection(cwd, "wechat", DEFAULT_CONFIG, normalizeConfig); }