AGENTS.md@packages/chat-gateway/src/adapters · git:20260919.9482906 · 2026-09-19 · sha256 672cbb7673426c1d
AGENTS.md@packages/chat-gateway/src/adapters git:20260919.9482906A
Immutable. This exact content is served forever at /api/v1/blob/672cbb7673426c1d.
# DOX — packages/chat-gateway/src/adapters
Files in this directory. One row per source file. See change: add-chat-gateway.
| File | Purpose |
|------|---------|
| `base.ts` | VENDORED from `@gamalan/pi-gateway@1.10.1` (MIT — see `../../NOTICE`) with ADDITIVE changes only: `InteractiveResponse` gained an optional `userId`, and the channel-provisioning surface was added (`ChannelOverwrite`, `ProvisionChannelInput`, and the OPTIONAL `provisionChannel`/`setChannelOverwrites`/`renameChannel`). The provisioning methods are optional by design — a platform without channel management is unaffected, and the provisioner fails closed (`platform_cannot_provision`) rather than appearing healthy. `provisionChannel` MUST carry overwrites in the create call and MUST NOT create-then-patch; `setChannelOverwrites` MUST be awaited (a caller may not report success before the platform reflects the new access). The platform-adapter contract: `PlatformAdapter` (initialize/start/stop/sendMessage/editMessage/deleteMessage/setTyping/getStatus/sendInteractive/cleanupInteractive + the three provisioning methods), `BaseAdapter` with its generic text fallback, and the `InteractivePrompt`/`InteractiveResponse`/`PlatformMessage`/`AdapterCallbacks`/`PlatformConfig` shapes. `userId` is REQUIRED for L1/L4 authorization of a click (a group-channel member who is not allowlisted can see the buttons, so rendering is not a grant); missing ⇒ treated as unknown ⇒ refused. Pure — no imports, no side effects. Re-vendor the rest; only these are intentionally added. |
| `discord-payload.ts` | PURE prompt↔Discord mapping, deliberately free of any `discord.js` import so it is unit-testable with no client and no network. `toDiscordControl` (select→string-select, confirm→Yes/No buttons, input/editor→modal, notify→message, unknown→message, >25 options degrades to message rather than silently truncating), the length-safe round-trippable `customIdFor`/`parseCustomId` codec (inline `p1\|…` under Discord's 100-char budget, else a bounded `p2\|token` memo), `DISCORD_MESSAGE_LIMIT`, and `chunkForDiscord` (splits on the last newline/space; hard-splits an over-long token; never truncates; `join("") === input`). Also owns the CHANNEL-PROVISIONING payload: `channelCreatePayload`/`channelOverwrites` PREPEND the platform-default-role deny (`@everyone`'s id IS the guild id, so the deny travels in the create call with no lookup) and there is deliberately NO create-without-overwrites shape, so no path can open a world-readable window; `channelNameFor` slugifies and caps at Discord's 100 chars. `VIEW_CHANNEL` is a literal (`1n << 10n`) to keep this module free of `discord.js`. And the delegation RULE (`assignersForRole` over `MemberSummary`/`RoleSummary`): the owner always qualifies, anyone else needs Manage Roles AND a highest-role position STRICTLY ABOVE the target, because Discord refuses a manager assigning at or above their own level — naming them would promise a delegation the platform rejects. `RoleAssigners` = `assigners` | `unavailable{missingPermission}`, never an empty list standing in for "cannot tell". |
| `discord.ts` | The `discord.js` transport implementing the vendored contract — REPLACES upstream's `discord.ts` (which used the ambient global `WebSocket`, derived the bot id from the token prefix, and re-armed a reconnect timer from `onclose` after `stop()`). Transport-only by design (task 2.2): no `child_process`/`spawn`, no `--mode rpc`, no `createServer`/`node:http`/`node:net` — it never learns about sessions or servers. `stop()` sets a stopped flag BEFORE disconnecting so nothing reconnects. `sendInteractive`/`handleInteraction` defers before work that may exceed Discord's 3s ack window (C5/F5) so the client never sees "interaction failed"; every emitted `InteractiveResponse` carries `interaction.user.id` for edge authorization. `initialize()` creates the client + logs in (the server entry MUST call it before `start()`); `allowedChannels` drops non-opted-in guild channels at the adapter; thread messages carry `metadata.parentChannelId`. Channel provisioning goes through the injectable `DiscordChannelOps` port (`create`/`update`/`guildIdFor`) so a test records the FULL call sequence with no connection; the default impl wraps `guild.channels.create`/`channel.edit` and maps the dependency-free overwrite data onto discord.js's resolvable shape. Role delegation is a SECOND injectable port, `DiscordMemberOps` (`assignersForRoles`), BATCHED so R roles cost ONE `members.fetch()` + ONE `roles.fetch()` rather than R of each. Its real impl reads the role list only when it holds Manage Roles and returns `unavailable` naming the missing permission otherwise, and names the Server Members intent when `members.fetch()` fails (the privileged-intent cause an operator can act on) — never an empty list. `toPlatformMessage` (module-level, extracted to keep `handleMessage` within the complexity budget) sets `bot`/`webhook`/`roleIds` so the EDGE can refuse non-humans and resolve role→tier, not just this adapter's own drop. |
| `fake.ts` | Socket-less FAKE platform for the L3 harness (`PI_CHAT_GATEWAY_FAKE=1` lists delegation, `=nolist` reports it unavailable), ENV-GUARDED like `PI_BROWSER_RELAY_FAKE` so no normal install can construct it — the docker harness carries no Discord credential, and five of the seven L3 team-controls rows need a platform to talk TO. Driven over the FILESYSTEM, deliberately not a wire surface: a spec APPENDS to `inbound.jsonl` to act as a chat user and READS `outbound.jsonl` for what the layer posted (mirror frames, elision markers, refusal replies), so there is no test-only plugin message an install could reach. `createFakeAdapterFromEnv` is the only entry point; `initialize()` truncates both scratch files so a spec starts from empty and its assertions are exact rather than "somewhere in history". The dir override treats an EMPTY `PI_CHAT_GATEWAY_FAKE_DIR` as unset (`override.length > 0`, not `??`): docker compose passes the empty string through for an unset var, so `??` yielded `dir: ""` and the fixture died in `mkdir ''`. Delegation reuses the REAL `assignersForRole` rule INCLUDING a non-owner who qualifies — a stub answering by a different rule would make F1/F2 prove nothing about the shipped rule — and `nolist` answers EVERY asked role with `unavailable` naming the missing permission, never a missing key (the caller reads a missing key as an empty roster) and never an empty `assigners` list. |
| `__tests__/recording-adapter.ts` | The in-memory L3 fake: a full `PlatformAdapter` that records ordered `sent`/`edited`/`deleted`/`typing`/`interactive`/`cleaned` calls, with `emitMessage`/`emitInteractiveResponse` drivers and `reset()`. Keeps the F/X rows deterministic in CI with no live Discord. Also the CHANNEL-PROVISIONING fixture (10i.1): `provisionCalls` records every create/set-overwrites/rename IN ORDER with timestamps, `provisionCallsOfKind` filters them (the create-then-patch assertion), and `failProvision`/`failOverwrites`/`overwriteDelayMs`+`releaseHeldOverwrites` inject the fault and slowness the X1/X3/X4 rows need. |