@open-everywhere/provider-discord 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +47 -0
  4. package/agent/access/discord-grant-click.ts +237 -0
  5. package/agent/approvals/discord-admin.ts +227 -0
  6. package/agent/approvals/discord-card.ts +248 -0
  7. package/agent/approvals/discord-flow.ts +469 -0
  8. package/agent/approvals/discord-scope-label.ts +27 -0
  9. package/agent/channels/discord-approval-card.ts +373 -0
  10. package/agent/channels/discord-backscroll.ts +593 -0
  11. package/agent/channels/discord-bot-name.ts +113 -0
  12. package/agent/channels/discord-channel-info.ts +83 -0
  13. package/agent/channels/discord-directory.ts +179 -0
  14. package/agent/channels/discord-dispatch-target.ts +103 -0
  15. package/agent/channels/discord-events.ts +175 -0
  16. package/agent/channels/discord-front.ts +355 -0
  17. package/agent/channels/discord-gateway.ts +3256 -0
  18. package/agent/channels/discord-identity.ts +269 -0
  19. package/agent/channels/discord-interaction-defer.ts +123 -0
  20. package/agent/channels/discord-message-ops.ts +127 -0
  21. package/agent/channels/discord-origin-codec.ts +105 -0
  22. package/agent/channels/discord-post-arm.ts +230 -0
  23. package/agent/channels/discord-post.ts +463 -0
  24. package/agent/channels/discord-read.ts +140 -0
  25. package/agent/channels/discord-rest.ts +85 -0
  26. package/agent/channels/discord.ts +244 -0
  27. package/agent/commands/discord-command-sync.ts +285 -0
  28. package/agent/commands/discord-commands.ts +291 -0
  29. package/agent/contributions.ts +31 -0
  30. package/agent/docs-generated.ts +15 -0
  31. package/agent/identity/discord-link-consent.ts +124 -0
  32. package/agent/ops.ts +14 -0
  33. package/agent/provider-events.ts +86 -0
  34. package/agent/register.ts +147 -0
  35. package/agent/surface-parity.ts +95 -0
  36. package/dist/agent/access/discord-grant-click.d.ts +25 -0
  37. package/dist/agent/access/discord-grant-click.d.ts.map +1 -0
  38. package/dist/agent/access/discord-grant-click.js +210 -0
  39. package/dist/agent/access/discord-grant-click.js.map +1 -0
  40. package/dist/agent/approvals/discord-admin.d.ts +63 -0
  41. package/dist/agent/approvals/discord-admin.d.ts.map +1 -0
  42. package/dist/agent/approvals/discord-admin.js +219 -0
  43. package/dist/agent/approvals/discord-admin.js.map +1 -0
  44. package/dist/agent/approvals/discord-card.d.ts +102 -0
  45. package/dist/agent/approvals/discord-card.d.ts.map +1 -0
  46. package/dist/agent/approvals/discord-card.js +217 -0
  47. package/dist/agent/approvals/discord-card.js.map +1 -0
  48. package/dist/agent/approvals/discord-flow.d.ts +109 -0
  49. package/dist/agent/approvals/discord-flow.d.ts.map +1 -0
  50. package/dist/agent/approvals/discord-flow.js +375 -0
  51. package/dist/agent/approvals/discord-flow.js.map +1 -0
  52. package/dist/agent/approvals/discord-scope-label.d.ts +3 -0
  53. package/dist/agent/approvals/discord-scope-label.d.ts.map +1 -0
  54. package/dist/agent/approvals/discord-scope-label.js +27 -0
  55. package/dist/agent/approvals/discord-scope-label.js.map +1 -0
  56. package/dist/agent/channels/discord-approval-card.d.ts +81 -0
  57. package/dist/agent/channels/discord-approval-card.d.ts.map +1 -0
  58. package/dist/agent/channels/discord-approval-card.js +303 -0
  59. package/dist/agent/channels/discord-approval-card.js.map +1 -0
  60. package/dist/agent/channels/discord-backscroll.d.ts +209 -0
  61. package/dist/agent/channels/discord-backscroll.d.ts.map +1 -0
  62. package/dist/agent/channels/discord-backscroll.js +505 -0
  63. package/dist/agent/channels/discord-backscroll.js.map +1 -0
  64. package/dist/agent/channels/discord-bot-name.d.ts +19 -0
  65. package/dist/agent/channels/discord-bot-name.d.ts.map +1 -0
  66. package/dist/agent/channels/discord-bot-name.js +98 -0
  67. package/dist/agent/channels/discord-bot-name.js.map +1 -0
  68. package/dist/agent/channels/discord-channel-info.d.ts +22 -0
  69. package/dist/agent/channels/discord-channel-info.d.ts.map +1 -0
  70. package/dist/agent/channels/discord-channel-info.js +65 -0
  71. package/dist/agent/channels/discord-channel-info.js.map +1 -0
  72. package/dist/agent/channels/discord-directory.d.ts +11 -0
  73. package/dist/agent/channels/discord-directory.d.ts.map +1 -0
  74. package/dist/agent/channels/discord-directory.js +170 -0
  75. package/dist/agent/channels/discord-directory.js.map +1 -0
  76. package/dist/agent/channels/discord-dispatch-target.d.ts +15 -0
  77. package/dist/agent/channels/discord-dispatch-target.d.ts.map +1 -0
  78. package/dist/agent/channels/discord-dispatch-target.js +99 -0
  79. package/dist/agent/channels/discord-dispatch-target.js.map +1 -0
  80. package/dist/agent/channels/discord-events.d.ts +58 -0
  81. package/dist/agent/channels/discord-events.d.ts.map +1 -0
  82. package/dist/agent/channels/discord-events.js +150 -0
  83. package/dist/agent/channels/discord-events.js.map +1 -0
  84. package/dist/agent/channels/discord-front.d.ts +115 -0
  85. package/dist/agent/channels/discord-front.d.ts.map +1 -0
  86. package/dist/agent/channels/discord-front.js +279 -0
  87. package/dist/agent/channels/discord-front.js.map +1 -0
  88. package/dist/agent/channels/discord-gateway.d.ts +783 -0
  89. package/dist/agent/channels/discord-gateway.d.ts.map +1 -0
  90. package/dist/agent/channels/discord-gateway.js +2591 -0
  91. package/dist/agent/channels/discord-gateway.js.map +1 -0
  92. package/dist/agent/channels/discord-identity.d.ts +110 -0
  93. package/dist/agent/channels/discord-identity.d.ts.map +1 -0
  94. package/dist/agent/channels/discord-identity.js +201 -0
  95. package/dist/agent/channels/discord-identity.js.map +1 -0
  96. package/dist/agent/channels/discord-interaction-defer.d.ts +33 -0
  97. package/dist/agent/channels/discord-interaction-defer.d.ts.map +1 -0
  98. package/dist/agent/channels/discord-interaction-defer.js +102 -0
  99. package/dist/agent/channels/discord-interaction-defer.js.map +1 -0
  100. package/dist/agent/channels/discord-message-ops.d.ts +3 -0
  101. package/dist/agent/channels/discord-message-ops.d.ts.map +1 -0
  102. package/dist/agent/channels/discord-message-ops.js +123 -0
  103. package/dist/agent/channels/discord-message-ops.js.map +1 -0
  104. package/dist/agent/channels/discord-origin-codec.d.ts +7 -0
  105. package/dist/agent/channels/discord-origin-codec.d.ts.map +1 -0
  106. package/dist/agent/channels/discord-origin-codec.js +98 -0
  107. package/dist/agent/channels/discord-origin-codec.js.map +1 -0
  108. package/dist/agent/channels/discord-post-arm.d.ts +3 -0
  109. package/dist/agent/channels/discord-post-arm.d.ts.map +1 -0
  110. package/dist/agent/channels/discord-post-arm.js +195 -0
  111. package/dist/agent/channels/discord-post-arm.js.map +1 -0
  112. package/dist/agent/channels/discord-post.d.ts +120 -0
  113. package/dist/agent/channels/discord-post.d.ts.map +1 -0
  114. package/dist/agent/channels/discord-post.js +426 -0
  115. package/dist/agent/channels/discord-post.js.map +1 -0
  116. package/dist/agent/channels/discord-read.d.ts +40 -0
  117. package/dist/agent/channels/discord-read.d.ts.map +1 -0
  118. package/dist/agent/channels/discord-read.js +128 -0
  119. package/dist/agent/channels/discord-read.js.map +1 -0
  120. package/dist/agent/channels/discord-rest.d.ts +33 -0
  121. package/dist/agent/channels/discord-rest.d.ts.map +1 -0
  122. package/dist/agent/channels/discord-rest.js +75 -0
  123. package/dist/agent/channels/discord-rest.js.map +1 -0
  124. package/dist/agent/channels/discord.d.ts +3 -0
  125. package/dist/agent/channels/discord.d.ts.map +1 -0
  126. package/dist/agent/channels/discord.js +230 -0
  127. package/dist/agent/channels/discord.js.map +1 -0
  128. package/dist/agent/commands/discord-command-sync.d.ts +66 -0
  129. package/dist/agent/commands/discord-command-sync.d.ts.map +1 -0
  130. package/dist/agent/commands/discord-command-sync.js +224 -0
  131. package/dist/agent/commands/discord-command-sync.js.map +1 -0
  132. package/dist/agent/commands/discord-commands.d.ts +45 -0
  133. package/dist/agent/commands/discord-commands.d.ts.map +1 -0
  134. package/dist/agent/commands/discord-commands.js +266 -0
  135. package/dist/agent/commands/discord-commands.js.map +1 -0
  136. package/dist/agent/contributions.d.ts +17 -0
  137. package/dist/agent/contributions.d.ts.map +1 -0
  138. package/dist/agent/contributions.js +31 -0
  139. package/dist/agent/contributions.js.map +1 -0
  140. package/dist/agent/docs-generated.d.ts +3 -0
  141. package/dist/agent/docs-generated.d.ts.map +1 -0
  142. package/dist/agent/docs-generated.js +13 -0
  143. package/dist/agent/docs-generated.js.map +1 -0
  144. package/dist/agent/identity/discord-link-consent.d.ts +13 -0
  145. package/dist/agent/identity/discord-link-consent.d.ts.map +1 -0
  146. package/dist/agent/identity/discord-link-consent.js +111 -0
  147. package/dist/agent/identity/discord-link-consent.js.map +1 -0
  148. package/dist/agent/ops.d.ts +2 -0
  149. package/dist/agent/ops.d.ts.map +1 -0
  150. package/dist/agent/ops.js +13 -0
  151. package/dist/agent/ops.js.map +1 -0
  152. package/dist/agent/provider-events.d.ts +3 -0
  153. package/dist/agent/provider-events.d.ts.map +1 -0
  154. package/dist/agent/provider-events.js +78 -0
  155. package/dist/agent/provider-events.js.map +1 -0
  156. package/dist/agent/register.d.ts +5 -0
  157. package/dist/agent/register.d.ts.map +1 -0
  158. package/dist/agent/register.js +125 -0
  159. package/dist/agent/register.js.map +1 -0
  160. package/dist/agent/surface-parity.d.ts +3 -0
  161. package/dist/agent/surface-parity.d.ts.map +1 -0
  162. package/dist/agent/surface-parity.js +90 -0
  163. package/dist/agent/surface-parity.js.map +1 -0
  164. package/package.json +185 -0
  165. package/tsconfig.build.json +22 -0
  166. package/tsconfig.json +15 -0
@@ -0,0 +1,85 @@
1
+ // discord-rest — THE ONE place a Discord REST WRITE call leaves this host (OPE-1236).
2
+ //
3
+ // WHY THIS SEAM EXISTS. Every Discord write in the reply/message-ops path used to be a bare `fetch`
4
+ // with no 429 handling. Discord answers a rate-limited call with HTTP 429, and it states the delay
5
+ // TWICE: in the `Retry-After` header AND in a JSON `retry_after` body field (fractional seconds).
6
+ // The body field is authoritative — Discord's own docs put the precise delay there — so the shared
7
+ // header-only backoff would miss it. This funnel wraps every write call in the shared
8
+ // `withRetryAfter` primitive and feeds it a body-precedence override, so a Discord write honours the
9
+ // delay the vendor actually named. See `docs/extending/provider-rate-limits.md`.
10
+ //
11
+ // 🔴 WHAT THIS SEAM COVERS, EXACTLY. Every Discord WRITE in this package: the reply feature's write
12
+ // seam (`discord-post.ts`), the ancillary message ops (`discord-message-ops.ts`), and — since
13
+ // OPE-1311 — the GATEWAY's own five writes (`discord-gateway.ts`: the completion post-back, the
14
+ // open-DM, the typing indicator, the reaction and the thread open). Every `fetch` in those three
15
+ // files goes through here.
16
+ //
17
+ // It does NOT cover the four READ channels (`discord-read.ts`, `discord-backscroll.ts`,
18
+ // `discord-directory.ts`, `discord-bot-name.ts`), which still hold their own raw `fetch`.
19
+ // `discord-rest-seam-test.ts` § 3 holds that KNOWN-UNCOVERED inventory and reds when a NEW file grows
20
+ // its own bare `fetch(` (a write that would bypass the seam) or a covered file regrows one, so the
21
+ // gap cannot widen unnoticed.
22
+ //
23
+ // The gateway's writes are fail-silent, so they do not all take the shared policy: the two ephemeral
24
+ // affordances (typing, the reaction) pass a tighter one, because both are awaited on the turn's own
25
+ // latency path. `discord-gateway.ts`'s `AFFORDANCE_RETRY` carries that reasoning.
26
+ //
27
+ // The bot token is a SECRET — it rides only the caller's header, never through here, and no response
28
+ // body ever reaches an error string. `operation` is the id-free vendor route SHAPE (e.g.
29
+ // `POST /channels/{id}/messages`) that rides the NAMED `RateLimitExhaustedError`; keep ids out of it,
30
+ // so every field on that error stays safe to log.
31
+
32
+ import { withRetryAfter, type RetryPolicy } from "@open-everywhere/core/agent/adapter-api.js";
33
+
34
+ /** The provider surface name on the NAMED rate-limit failure. */
35
+ export const DISCORD_SURFACE = "discord";
36
+
37
+ /** The subset of `fetch` this seam drives — injectable so a test scripts the 429 sequence. */
38
+ export type DiscordFetchLike = (url: string, init: RequestInit) => Promise<Response>;
39
+
40
+ /** Transport and retry seams. A test injects both; live code passes neither. */
41
+ export interface DiscordFetchDeps {
42
+ readonly fetch?: DiscordFetchLike;
43
+ readonly retry?: RetryPolicy;
44
+ }
45
+
46
+ /**
47
+ * Read the honoured delay from a Discord 429 body, in milliseconds, or `undefined` when the body
48
+ * carries no usable `retry_after` (then the seam falls back to the header). Discord states
49
+ * `retry_after` in SECONDS — fractional, e.g. `0.75` — so this multiplies by 1000 rather than
50
+ * routing through the header parser, which reads integer seconds only.
51
+ *
52
+ * 🔴 NEVER THROWS, and reads a CLONE. The primitive cancels the original body right after this
53
+ * returns; reading the clone leaves the original intact for that cancel. A body that is missing,
54
+ * not JSON, or carries no numeric `retry_after` answers `undefined` — never a wrong delay.
55
+ */
56
+ export async function discordBodyRetryAfterMs(res: Response): Promise<number | undefined> {
57
+ try {
58
+ const body = (await res.clone().json()) as { retry_after?: unknown } | null;
59
+ const seconds = body?.retry_after;
60
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) return undefined;
61
+ return seconds * 1000;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Issue ONE Discord REST call with the shared 429 backoff, honouring the body's `retry_after` over
69
+ * the header. `operation` is the id-free route shape for the named failure. `deps.retry` lets a test
70
+ * inject a stub sleep/clock; live callers pass nothing.
71
+ *
72
+ * Returns the FIRST non-429 response untouched — the caller reads `res.ok`/`res.status`/`res.json()`
73
+ * exactly as it did before this seam existed. Throws `RateLimitExhaustedError` when a 429 outlasts
74
+ * the bounded attempts, and re-throws a transport error unretried (an unanswered write may have
75
+ * applied).
76
+ */
77
+ export function discordFetch(operation: string, url: string, init: RequestInit, deps: DiscordFetchDeps = {}): Promise<Response> {
78
+ const send = (): Promise<Response> => (deps.fetch ?? (globalThis.fetch as DiscordFetchLike))(url, init);
79
+ return withRetryAfter(send, {
80
+ surface: DISCORD_SURFACE,
81
+ operation,
82
+ retryAfterMs: (res) => discordBodyRetryAfterMs(res as Response),
83
+ ...deps.retry,
84
+ });
85
+ }
@@ -0,0 +1,244 @@
1
+ // The Discord surface. Wires Discord's HTTP Interactions (slash commands) → an
2
+ // OpenEverywhere session, and — the point of the "Discord-binding" slice — populates the
3
+ // session auth context with the REAL Discord identity (guild/channel/user ids + the
4
+ // channel type) so the identity seam (agent/identity/binding.ts) resolves the actual
5
+ // workspace (the guild/server — the org boundary), surface (the channel), mode
6
+ // (channel/dm/mpim), and visibility (fail-closed private) instead of OE_* env
7
+ // stand-ins. Everything downstream — memory partition, access resolver, routine
8
+ // dispatch — then keys off real Discord identity with no further change.
9
+ //
10
+ // Eve's Discord channel is interaction-based, not message-based: there is no
11
+ // app_mention / message event, only `onCommand(ctx, interaction)` for an explicitly
12
+ // invoked slash command. A slash command is already a bot-directed act — you can't
13
+ // invoke the app without targeting it — so it is the natural analogue of Slack's
14
+ // mention-gating: every onCommand is, by construction, "mentioned". We accept both
15
+ // DMs and guild commands and still drop bot-authored invocations, then override
16
+ // onCommand only to attach OUR richer auth via buildDiscordAuth (the eve default
17
+ // derives a thinner, guild-scoped auth). Kept MINIMAL for launch — identity binding +
18
+ // dispatch only; no status-UX / onboarding extras (cf. slack.ts).
19
+ //
20
+ // Credentials: omitted here, so eve falls back to DISCORD_PUBLIC_KEY /
21
+ // DISCORD_APPLICATION_ID / DISCORD_BOT_TOKEN (see .env.example). Swap the credential
22
+ // broker later without touching identity. Capturing the channel type / guild presence is a
23
+ // RAW-fact job; the security-critical interpretation (fail-closed to private) lives in
24
+ // the binding (discordVisibility).
25
+ //
26
+ // FRONTED (see discord-front.ts): the wrapped eve channel sits on an internal route; the
27
+ // public Interactions Endpoint URL is our front, which answers `oe-approval:` approval-card
28
+ // button clicks (admin approvals — approvals/discord-flow.ts) and forwards everything else
29
+ // (PING, slash commands, eve's own HITL clicks) here unchanged. Same option-(c) pattern as
30
+ // the Slack channel (slack-front.ts) and for the same reason: eve exposes no onComponent hook.
31
+ //
32
+ // A THIRD, separate URL (see discord-events.ts) carries Discord's app-level WEBHOOK EVENTS —
33
+ // APPLICATION_AUTHORIZED / APPLICATION_DEAUTHORIZED, i.e. install/uninstall — into the audit
34
+ // trail. Configured on the developer portal's Webhooks page, not the Interactions one.
35
+
36
+ import {
37
+ discordChannel,
38
+ type DiscordContext,
39
+ type DiscordCommandInteraction,
40
+ } from "eve/channels/discord";
41
+ import { buildDiscordAuth, threadParentFromRawChannel, discordMemberPendingFromRaw, type OpenEverywhereDiscordAuth } from "./discord-identity.js";
42
+ import {
43
+ finalMessageActionForTurn,
44
+ NO_REPLY_SENTINEL_REASON,
45
+ auditNoReplySentinelObserved,
46
+ isBareStop,
47
+ consumeRepliedThisTurn,
48
+ dropClosingProse,
49
+ resolvePartition,
50
+ authPrincipalId,
51
+ type ChatAuditPartition,
52
+ } from "@open-everywhere/core/agent/adapter-api.js";
53
+ import { frontDiscordChannel, INTERNAL_DISCORD_ROUTE } from "./discord-front.js";
54
+ import { withDiscordEventsRoute } from "./discord-events.js";
55
+
56
+ // The audit partition for a terminal event, rebuilt from the LIVE session state the same
57
+ // way slack.ts rebuilds its own (the completion handler has no auth context): guild →
58
+ // workspace, channel → surface, via the REAL identity seam so rows land exactly where the
59
+ // dispatch partition did. Null on an unidentifiable state (no channel id) — the caller
60
+ // then skips the row (fail-soft; auditing must never fail or post into the turn).
61
+ function auditPartitionForState(state: { channelId: string | null; guildId: string | null }): ChatAuditPartition | null {
62
+ if (!state.channelId) return null;
63
+ try {
64
+ return resolvePartition({
65
+ channel: { kind: "discord" },
66
+ session: { auth: { current: buildDiscordAuth({ userId: "", guildId: state.guildId ?? undefined, channelId: state.channelId }) } },
67
+ });
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ // Read the Discord numeric channel `type` from the raw interaction payload. The parsed
74
+ // interaction exposes ids (channel/guild/user) but not the channel kind, which lives in
75
+ // the payload's `channel` object. Used only to tell a DM (1) / group DM (3) from a guild
76
+ // text channel or thread; absent → the binding falls back to guild-presence for mode and
77
+ // fails closed on visibility. Defensive: the payload is Discord's raw JSON.
78
+ function channelTypeFromRaw(raw: Record<string, unknown>): string | undefined {
79
+ const ch = raw["channel"];
80
+ if (ch && typeof ch === "object") {
81
+ const t = (ch as Record<string, unknown>)["type"];
82
+ if (typeof t === "number") return String(t);
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ // Attach OUR richer Discord auth: raw guild/channel/user ids + the channel type (and,
88
+ // for a THREAD, its parent channel id — the raw payload's channel partial carries
89
+ // `parent_id`, so the thread→parent domain/spend grain of item 22 is FREE here, no REST
90
+ // lookup like the gateway's message path needs), which the binding then interprets into
91
+ // workspace/surface/mode/visibility. Drops invocations by another bot — we never act on
92
+ // a bot's command.
93
+ function authFromInteraction(interaction: DiscordCommandInteraction): OpenEverywhereDiscordAuth | null {
94
+ if (interaction.user.isBot) return null; // bot actor — drop
95
+ return buildDiscordAuth({
96
+ userId: interaction.user.id,
97
+ guildId: interaction.guildId,
98
+ channelId: interaction.channelId,
99
+ channelType: channelTypeFromRaw(interaction.raw),
100
+ parentChannelId: threadParentFromRawChannel(interaction.raw),
101
+ // Guest/unverified detection (OPE-60): the interaction payload carries the guild `member`
102
+ // object (incl. `member.pending`) for FREE — no privileged intent, no REST call. A pending
103
+ // member hasn't cleared membership screening → the binding demotes trust to external.
104
+ memberPending: discordMemberPendingFromRaw(interaction.raw),
105
+ });
106
+ }
107
+
108
+ const discordInteractionChannel = discordChannel({
109
+ // Internal mount — the public URL is the approval-click front (frontDiscordChannel below).
110
+ route: INTERNAL_DISCORD_ROUTE,
111
+ // 🔴 QUEUE, NEVER STEER (OPE-412) — eve's default `steer` cancels the turn in flight when a
112
+ // message arrives. Same reasoning as the Slack channel; see slack.ts for the full note.
113
+ turnPolicy: "queue",
114
+ onCommand: async (ctx: DiscordContext, interaction: DiscordCommandInteraction) => {
115
+ const auth = authFromInteraction(interaction);
116
+ if (auth === null) return null; // bot author / unactionable → ack without dispatch
117
+ try {
118
+ await ctx.discord.startTyping();
119
+ } catch {
120
+ /* typing indicator is best-effort */
121
+ }
122
+ return { auth };
123
+ },
124
+ events: {
125
+ // Authoring this handler REPLACES eve's Discord default for "turn.started" (verified in
126
+ // node_modules/eve/dist/.../discord/defaults.js: `async "turn.started"(e,t,n){await
127
+ // t.discord.startTyping()}`), so we replicate that default body verbatim first, matching the
128
+ // discipline channels/slack.ts's own turn.started comment documents.
129
+ //
130
+ // OPE-645: REPLY PRINCIPAL snapshot — the same T1.4 pattern channels/slack.ts,
131
+ // channels/teams.ts and discord-gateway.ts each use for their own: message.completed
132
+ // has no live auth ctx (eve rewrites session.auth.current on every delivery, and this channel
133
+ // runs `turnPolicy: "queue"`, so a later queued delivery can overwrite it before this turn's
134
+ // own message.completed fires), but turn.started does. authPrincipalId(ctx) is the exact read
135
+ // bindFrom(ctx).principalId falls back to for an ordinary human turn (identity/binding.ts) —
136
+ // snapshotting it here means the DROP row this handler writes since OPE-2006 names the same
137
+ // person the turn's egress.policy rows already do. It is no longer a `reply.post` row: nothing
138
+ // posts here.
139
+ "turn.started": async (_data, channel, ctx) => {
140
+ // Snapshot FIRST — the attribution must not depend on the typing call succeeding (the
141
+ // same discipline the Slack sibling states for its supervisor target snapshot).
142
+ (channel.state as unknown as Record<string, unknown>).oeReplyPrincipalId = authPrincipalId(ctx);
143
+ await channel.discord.startTyping();
144
+ },
145
+ // Override the reply-posting handler for ONE addition: a fired `watch` routine
146
+ // ends its turn with the silence sentinel when the watched state is unchanged
147
+ // (routines/watch.ts) — swallow it, never post it. Mirrors the Slack override
148
+ // (channels/slack.ts) and the Teams one, via the shared, unit-tested
149
+ // finalMessageAction() decision. Everything else matches eve's Discord default
150
+ // (tool-calls and empty final messages post nothing). Only routine fires teach
151
+ // the sentinel on this surface; slash-command replies are unaffected.
152
+ "message.completed": async (data, channel, ctx) => {
153
+ const sessionId = ctx?.session?.id; // stamped into the terminal audit rows → joins to the transcript
154
+ // OPE-1647: the TURN ID, so the stand-down silence mark (channels/stand-down-silence.ts) can
155
+ // prove it belongs to THIS turn. Same extraction idiom this file already uses for the §POSTED
156
+ // consume below.
157
+ // 🔴 SINCE OPE-2006 IT SERVES THE SILENCE DECISION ALONE. It is read once here and handed to
158
+ // `finalMessageActionForTurn`, whose `consumeStandDownSilenceThisTurn(turnId)` is its only
159
+ // reader on this path. There is no reply audit below to stamp it onto: this surface DROPS its
160
+ // closing prose, so the durable latch that guarded the old post has no door left to shut here.
161
+ const turnId = (ctx as unknown as { session?: { turn?: { id?: string } } })?.session?.turn?.id;
162
+ const action = finalMessageActionForTurn(data.finishReason, data.message, turnId);
163
+ // An interim tool-call step, not the turn's end — don't consume the reply signal here:
164
+ // the tool this step is about to run may be the `reply` call itself (mirrors slack.ts).
165
+ if (action.kind === "buffer-tool-status") return;
166
+ // Terminal decision — read-and-reset the reply-tool signal once, on every terminal
167
+ // branch (post OR silent), so it never bleeds into the next turn (same contract as
168
+ // slack.ts's reactedThisTurn/repliedThisTurn consume). TURN-ID KEYED (OPE-805): only THIS
169
+ // turn's own mark answers true — see reply-signal.ts's header.
170
+ const repliedThisTurn = consumeRepliedThisTurn((ctx as unknown as { session?: { turn?: { id?: string } } })?.session?.turn?.id);
171
+ if (action.kind !== "post") {
172
+ // Only log a genuinely JUDGED silence (the NO_REPLY sentinel, or the no_reply
173
+ // tool) — an ordinary empty completion (nothing to say, no judgment made) isn't
174
+ // worth a log line.
175
+ if (action.kind === "silent" && action.reason !== "empty final message") {
176
+ console.log(`[open-everywhere:discord] judged silence (${action.reason}) — nothing posted (channel ${channel.state.channelId ?? "?"})`);
177
+ }
178
+ // ACTIONS-ONLY posture (audit-wedge build plan §1c, 2026-07-14): a judged silence is not
179
+ // an audited action — the transcript answers "why did it stay quiet?", not the audit log.
180
+ // The console line above stays; the bare-stop WARN below stays (an anomaly, not a silence).
181
+ //
182
+ // ONE exception (OPE-890): the model ended the turn on the bare NO_REPLY sentinel — the
183
+ // TOOL route already carries an always-on `reply.no_reply_observed` row (tools/no_reply.ts,
184
+ // OPE-649); this is the sentinel route's twin, same action name, best-effort. Reached only
185
+ // for a genuine bare-sentinel turn — finalMessageActionForTurn already resolves the tool
186
+ // route to its own `no_reply tool: …` reason, so a tool-called turn never lands here.
187
+ if (action.kind === "silent" && action.reason === NO_REPLY_SENTINEL_REASON) {
188
+ const p = auditPartitionForState(channel.state);
189
+ if (p) await auditNoReplySentinelObserved(p, sessionId).catch(() => undefined);
190
+ }
191
+ // Bare-stop enforcement (see slack-events.ts's isBareStop header for the full
192
+ // rationale — DETECT-AND-LOG, not a hard gate). `react` is Slack-only (no
193
+ // Discord equivalent wired), but the reply-tool family (reply-signal.ts) IS armed
194
+ // here, so a `reply`-then-silent turn must not be mis-flagged. The bare stop is
195
+ // ALSO covered by the discord.no_reply row above (reason "empty final message").
196
+ if (action.kind === "silent" && isBareStop(action, repliedThisTurn)) {
197
+ console.warn(
198
+ `[open-everywhere:discord] BARE STOP — turn ended with no reply and no no_reply (channel ${channel.state.channelId ?? "?"}) — this usually means a judgment call went missing, not that one was made.`,
199
+ );
200
+ }
201
+ return;
202
+ }
203
+ // 🔴 THE CLOSING PROSE IS DROPPED, NOT POSTED (OPE-2006). This branch used to route the
204
+ // terminal reply through `postTerminalProse`, with a raw `channel.discord.post` fallback for an
205
+ // unidentifiable state. Both are gone: the `reply` tool is the only channel a seat's words
206
+ // leave by, so a turn that ended in prose and never called `reply` has said nothing here. The
207
+ // row is the record — no re-send, no queue, no host-side re-post.
208
+ // WHAT TELLS THE MODEL is the seat prompt: Discord declares `closingProseDropped`, so a
209
+ // conversational seat here reads the `reply-only` delivery arm, whose copy states that the
210
+ // turn-final text is discarded — inside its persona when the token is supervisor-shaped, and
211
+ // through the `delivery` slot when it is not (instructions/access.ts `renderDeliverySlot`).
212
+ {
213
+ const p = auditPartitionForState(channel.state);
214
+ if (p) {
215
+ await dropClosingProse({
216
+ row: {
217
+ org: p.org,
218
+ platform: p.platform,
219
+ workspace: p.workspace,
220
+ surface: p.surface,
221
+ principalId: (channel.state as unknown as Record<string, unknown>).oeReplyPrincipalId as string | undefined,
222
+ },
223
+ chars: action.text.length,
224
+ ...(sessionId ? { sessionId } : {}),
225
+ anchor: { channelId: channel.state.channelId ?? null },
226
+ log: (m) => console.warn(`[open-everywhere:discord] ${m} (channel ${channel.state.channelId ?? "?"})`),
227
+ });
228
+ } else {
229
+ // No resolvable partition, so there is no row to file it under — the drop still happens and
230
+ // the log line is the whole record, the same honest degradation the silence branches above
231
+ // take on an unidentifiable state.
232
+ console.warn(
233
+ `[open-everywhere:discord] closing prose DROPPED (${action.text.length}c) — nothing posted, and no partition to audit it under (channel ${channel.state.channelId ?? "?"})`,
234
+ );
235
+ }
236
+ }
237
+ },
238
+ },
239
+ });
240
+
241
+ // FRONTED (interactions) + the app-level WEBHOOK EVENTS route (discord-events.ts): a separate,
242
+ // additive URL that audits APPLICATION_AUTHORIZED / APPLICATION_DEAUTHORIZED so install/uninstall
243
+ // is observable. Observability only — it gates nothing and never reaches a session.
244
+ export default withDiscordEventsRoute(frontDiscordChannel(discordInteractionChannel));
@@ -0,0 +1,285 @@
1
+ // THE DISCORD HALF of the surface-command reconcile plane (OPE-1687) — the deployment registers its
2
+ // own "/oe" command instead of waiting for an operator to run a CLI.
3
+ //
4
+ // WHAT IT DOES, ONCE PER PROCESS START: read the commands Discord holds for this application at each
5
+ // declared guild, project both sides onto the fields this deployment actually declares, and write
6
+ // ONLY when they differ. No difference ⇒ no write, so a warm deployment's steady-state cost is one
7
+ // vendor read per cold start.
8
+ //
9
+ // 🔴 THE WRITE IS ADDITIVE — `POST …/commands`, NEVER the bulk `PUT`. Discord's bulk PUT is a full
10
+ // OVERWRITE of every command at that scope, which is the right contract for the operator CLI
11
+ // (`scripts/register-discord-commands.ts`, run deliberately, documented as destructive) and the wrong
12
+ // one for an unattended boot. `docs/deploy/surfaces/discord.md` tells operators to register their own
13
+ // `/ask` command by hand at this same scope; a boot-time PUT of our one-command array would delete it
14
+ // silently on the next cold start. The per-command POST creates-or-updates by NAME and leaves every
15
+ // other command at the scope untouched, so this plane can only ever add or correct "/oe".
16
+ //
17
+ // 🔴 THE DIFF IS A PROJECTION, NOT A DEEP EQUAL. Discord's GET returns fields nobody declared — `id`,
18
+ // `application_id`, `version`, `guild_id`, `default_member_permissions`, `nsfw`, localisation maps —
19
+ // and omits `required` where it is false. A deep equal against the declared body would therefore
20
+ // differ EVERY time and turn "write only on a difference" into a write on every cold start. Both
21
+ // sides go through the same `projectCommand`, which keeps exactly the fields
22
+ // `buildOeDiscordCommandDefinition` sets and fills `required` with its Discord default.
23
+ //
24
+ // NEVER THROWS, AND NEVER BLOCKS BOOT FOR LONG. Every failure — no credential, a refused request, a
25
+ // timeout, malformed JSON — answers with an outcome the boot plane logs. A bot token never reaches an
26
+ // outcome's `detail`, and neither does a response body: a 401 says "401", not what Discord wrote back.
27
+ // The latency bound is two constants below, and both are load-bearing rather than tuning: no 429
28
+ // backoff at all, and one budget for the whole reconcile whatever the guild count.
29
+ //
30
+ // 🔴 ANY PROCESS THAT STARTS WITH BOTH CREDENTIALS WRITES TO THE DECLARED GUILD. The credential pair
31
+ // is the only gate, so a local `eve dev`/`eve start` run with the deployment's own `DISCORD_BOT_TOKEN`
32
+ // registers THAT CHECKOUT's command shape into the declared guild — see the warning in
33
+ // `agent/commands/discord-commands.ts`.
34
+
35
+ import type { RetryPolicy, SurfaceCommandOutcome, SurfaceCommandScope } from "@open-everywhere/core/agent/adapter-api.js";
36
+ import { discordFetch, type DiscordFetchLike } from "../channels/discord-rest.js";
37
+ import { buildOeDiscordCommandDefinition } from "./discord-commands.js";
38
+
39
+ /**
40
+ * 🔴 NO 429 BACKOFF AT ALL — the reconcile sits on the FIRST REQUEST's latency path.
41
+ *
42
+ * `withRetryAfter`'s defaults allow up to four attempts, a 30 s single wait and a 60 s total
43
+ * (`packages/core/agent/channels/rate-limit-retry.ts`), and its own header says a latency-critical
44
+ * caller must pass a tighter policy. A boot on a lambda is the strictest such caller there is: the
45
+ * cold start it rides is usually a Discord INTERACTION, which must be acknowledged inside 3 seconds.
46
+ * A 429 answered with `retry_after: 60` would otherwise sleep 30 s inside the boot's own top-level
47
+ * await, blow that ack, and possibly time the invocation out. So a rate-limited Discord is a plain
48
+ * failure here — the next cold start reads again, and the command list is stale meanwhile, which is
49
+ * the state it was already in.
50
+ *
51
+ * The same reasoning is why `discord-gateway.ts` gives its two ephemeral affordances `AFFORDANCE_RETRY`.
52
+ */
53
+ const BOOT_RETRY: RetryPolicy = { maxAttempts: 1 };
54
+
55
+ /** How long ONE Discord call may take. `AbortSignal.timeout` bounds a call, never a backoff sleep —
56
+ * that is what {@link BOOT_RETRY} removes. */
57
+ const CALL_TIMEOUT_MS = 1_500;
58
+
59
+ /**
60
+ * The ceiling on the WHOLE reconcile, across every declared guild and both of each guild's calls.
61
+ * Without it the worst case multiplies by declared guild, and "bounded" would depend on how many
62
+ * rooms an operator listed. A guild the budget cannot reach reports `failed` and is read again on the
63
+ * next cold start.
64
+ */
65
+ const RECONCILE_BUDGET_MS = 2_000;
66
+
67
+ const API_BASE = "https://discord.com/api/v10";
68
+
69
+ /** The projected shape both sides are compared in — exactly the fields this deployment declares. */
70
+ export interface ProjectedOption {
71
+ readonly name: string;
72
+ readonly description: string;
73
+ readonly type: number;
74
+ readonly required: boolean;
75
+ readonly min_value?: number;
76
+ readonly max_value?: number;
77
+ readonly options: readonly ProjectedOption[];
78
+ }
79
+
80
+ export interface ProjectedCommand {
81
+ readonly name: string;
82
+ readonly type: number;
83
+ readonly description: string;
84
+ readonly options: readonly ProjectedOption[];
85
+ }
86
+
87
+ function isObject(v: unknown): v is Record<string, unknown> {
88
+ return typeof v === "object" && v !== null;
89
+ }
90
+
91
+ function str(v: unknown): string {
92
+ return typeof v === "string" ? v : "";
93
+ }
94
+
95
+ function numOrUndefined(v: unknown): number | undefined {
96
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
97
+ }
98
+
99
+ function projectOptions(raw: unknown): readonly ProjectedOption[] {
100
+ if (!Array.isArray(raw)) return [];
101
+ const out: ProjectedOption[] = [];
102
+ for (const entry of raw) {
103
+ if (!isObject(entry)) continue;
104
+ const min = numOrUndefined(entry["min_value"]);
105
+ const max = numOrUndefined(entry["max_value"]);
106
+ out.push({
107
+ name: str(entry["name"]),
108
+ description: str(entry["description"]),
109
+ type: numOrUndefined(entry["type"]) ?? 0,
110
+ // Discord OMITS `required` when it is false, so the default is part of the projection. Without
111
+ // it every optional option reads as a difference and the reconcile writes on every cold start.
112
+ required: entry["required"] === true,
113
+ ...(min === undefined ? {} : { min_value: min }),
114
+ ...(max === undefined ? {} : { max_value: max }),
115
+ options: projectOptions(entry["options"]),
116
+ });
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Project one ApplicationCommand — declared or vendor-held — onto the fields this deployment sets.
123
+ * Returns undefined for anything that is not an object with a name, which is what a malformed or
124
+ * unexpected list entry looks like.
125
+ *
126
+ * OPTION ORDER IS PART OF THE PROJECTION. Discord renders a command's options in the order they were
127
+ * registered, so a reordered list is a real user-visible difference and must be written. Discord
128
+ * returns them in the registered order, so a steady state never reorders on its own.
129
+ */
130
+ export function projectCommand(raw: unknown): ProjectedCommand | undefined {
131
+ if (!isObject(raw)) return undefined;
132
+ const name = str(raw["name"]);
133
+ if (name.length === 0) return undefined;
134
+ return {
135
+ name,
136
+ // Discord's own default for `type` is 1 (CHAT_INPUT); the declaration sets it explicitly.
137
+ type: numOrUndefined(raw["type"]) ?? 1,
138
+ description: str(raw["description"]),
139
+ options: projectOptions(raw["options"]),
140
+ };
141
+ }
142
+
143
+ /** A stable string for one projected command, so two projections compare as text. */
144
+ function canonical(command: ProjectedCommand): string {
145
+ const opt = (o: ProjectedOption): unknown => [o.name, o.description, o.type, o.required, o.min_value ?? null, o.max_value ?? null, o.options.map(opt)];
146
+ return JSON.stringify([command.name, command.type, command.description, command.options.map(opt)]);
147
+ }
148
+
149
+ export interface DiscordCommandDrift {
150
+ /** Does Discord need a write for this command? */
151
+ readonly changed: boolean;
152
+ /** Why — one id-free line for the boot log. */
153
+ readonly reason: string;
154
+ }
155
+
156
+ /**
157
+ * Compare the command this deployment DECLARES against the list Discord HOLDS at one scope.
158
+ *
159
+ * `held` is the raw array Discord's GET returned. Only the entry whose name matches the declared
160
+ * command is considered: every other command at that scope belongs to someone else and this plane
161
+ * neither reads nor writes it.
162
+ */
163
+ export function discordCommandDrift(declared: unknown, held: unknown): DiscordCommandDrift {
164
+ const want = projectCommand(declared);
165
+ if (want === undefined) {
166
+ // A declaration this file cannot project is a code defect, not a vendor state. Refusing to write
167
+ // is the safe half: a bad body would replace a working command with a broken one.
168
+ return { changed: false, reason: "the declared command has no name — nothing was compared and nothing will be written" };
169
+ }
170
+ const list = Array.isArray(held) ? held : [];
171
+ const mine = list.map((entry) => projectCommand(entry)).find((entry) => entry !== undefined && entry.name === want.name);
172
+ if (mine === undefined) {
173
+ return { changed: true, reason: `"/${want.name}" is not registered at this scope` };
174
+ }
175
+ if (canonical(mine) !== canonical(want)) {
176
+ return { changed: true, reason: `"/${want.name}" is registered with a different shape` };
177
+ }
178
+ return { changed: false, reason: `"/${want.name}" already matches what this deployment declares` };
179
+ }
180
+
181
+ export interface DiscordCommandSyncDeps {
182
+ readonly fetch?: DiscordFetchLike;
183
+ /** Overrides the env reads. Live boot passes neither; a test passes both. */
184
+ readonly applicationId?: string;
185
+ readonly botToken?: string;
186
+ /** The ceiling on ONE call. Defaults to {@link CALL_TIMEOUT_MS}. */
187
+ readonly timeoutMs?: number;
188
+ /** The ceiling on the WHOLE reconcile. Defaults to {@link RECONCILE_BUDGET_MS}. */
189
+ readonly budgetMs?: number;
190
+ }
191
+
192
+ interface ScopeResult {
193
+ readonly status: "unchanged" | "updated" | "failed";
194
+ readonly detail: string;
195
+ }
196
+
197
+ /** Read + compare + (only on a difference) write, for ONE guild. Never throws. `msLeft` is what is
198
+ * left of the whole reconcile's budget, so no single guild can spend more than the rest of them. */
199
+ async function reconcileOneGuild(
200
+ guildId: string,
201
+ applicationId: string,
202
+ botToken: string,
203
+ deps: DiscordCommandSyncDeps,
204
+ msLeft: () => number,
205
+ ): Promise<ScopeResult> {
206
+ const declared = buildOeDiscordCommandDefinition();
207
+ const url = `${API_BASE}/applications/${applicationId}/guilds/${guildId}/commands`;
208
+ const headers = { authorization: `Bot ${botToken}`, "content-type": "application/json" };
209
+ const cap = deps.timeoutMs ?? CALL_TIMEOUT_MS;
210
+ const fetchDeps = { ...(deps.fetch ? { fetch: deps.fetch } : {}), retry: BOOT_RETRY };
211
+ /** This call's own ceiling: the per-call cap, or whatever is left of the reconcile's budget. */
212
+ const nextTimeout = (): number => Math.min(cap, msLeft());
213
+
214
+ if (nextTimeout() <= 0) {
215
+ return { status: "failed", detail: `guild ${guildId}: the boot budget was already spent on an earlier guild — read again on the next start` };
216
+ }
217
+
218
+ let held: unknown;
219
+ try {
220
+ const res = await discordFetch("GET /applications/{id}/guilds/{id}/commands", url, { method: "GET", headers, signal: AbortSignal.timeout(nextTimeout()) }, fetchDeps);
221
+ if (!res.ok) return { status: "failed", detail: `guild ${guildId}: the command list read returned ${res.status}` };
222
+ held = await res.json();
223
+ } catch (err) {
224
+ return { status: "failed", detail: `guild ${guildId}: the command list read failed (${err instanceof Error ? err.name : "error"})` };
225
+ }
226
+
227
+ const drift = discordCommandDrift(declared, held);
228
+ if (!drift.changed) return { status: "unchanged", detail: `guild ${guildId}: ${drift.reason}` };
229
+
230
+ if (nextTimeout() <= 0) {
231
+ return { status: "failed", detail: `guild ${guildId}: ${drift.reason}, and the boot budget was spent before the write — retried on the next start` };
232
+ }
233
+
234
+ try {
235
+ // CREATE-OR-UPDATE BY NAME. Discord treats a POST naming an existing command as an update of
236
+ // that command, and touches nothing else at the scope — see the header on why the bulk PUT is
237
+ // wrong here.
238
+ const res = await discordFetch(
239
+ "POST /applications/{id}/guilds/{id}/commands",
240
+ url,
241
+ { method: "POST", headers, body: JSON.stringify(declared), signal: AbortSignal.timeout(nextTimeout()) },
242
+ fetchDeps,
243
+ );
244
+ if (!res.ok) return { status: "failed", detail: `guild ${guildId}: ${drift.reason}, and the write returned ${res.status}` };
245
+ return { status: "updated", detail: `guild ${guildId}: ${drift.reason} — registered` };
246
+ } catch (err) {
247
+ return { status: "failed", detail: `guild ${guildId}: ${drift.reason}, and the write failed (${err instanceof Error ? err.name : "error"})` };
248
+ }
249
+ }
250
+
251
+ /**
252
+ * THE RECONCILER the discord bundle registers with core's command plane.
253
+ *
254
+ * Runs at boot, so it is gated on BOTH deployment credentials being present: `DISCORD_APPLICATION_ID`
255
+ * says which app this is and `DISCORD_BOT_TOKEN` authenticates the call. Either missing ⇒ `skipped`,
256
+ * which is the correct reading of a deployment that carries the bundle but no Discord app — and it is
257
+ * also what keeps every offline test tier network-free, since no test publishes an application id.
258
+ *
259
+ * 🔴 THESE TWO STAY ENV. They are the credential pair this deployment speaks as, which is exactly the
260
+ * `secret`/`identity` half of the config-as-code line. The GUILD ids do not: they are tracked literals
261
+ * in `agent/commands/discord-commands.ts` and arrive here as the declared scope.
262
+ */
263
+ export async function reconcileDiscordCommands(scope: SurfaceCommandScope, deps: DiscordCommandSyncDeps = {}): Promise<SurfaceCommandOutcome> {
264
+ const applicationId = deps.applicationId ?? process.env.DISCORD_APPLICATION_ID;
265
+ const botToken = deps.botToken ?? process.env.DISCORD_BOT_TOKEN;
266
+ if (!applicationId || !botToken) {
267
+ return {
268
+ status: "skipped",
269
+ detail: "DISCORD_APPLICATION_ID and DISCORD_BOT_TOKEN must both be set for this deployment to register its own commands.",
270
+ };
271
+ }
272
+
273
+ const deadline = Date.now() + (deps.budgetMs ?? RECONCILE_BUDGET_MS);
274
+ const msLeft = (): number => deadline - Date.now();
275
+ const results: ScopeResult[] = [];
276
+ for (const guildId of scope.scopes) {
277
+ results.push(await reconcileOneGuild(guildId, applicationId, botToken, deps, msLeft));
278
+ }
279
+ const detail = results.map((r) => r.detail).join("; ");
280
+ // A failure on ANY guild is the outcome, even when another guild succeeded: the boot log must not
281
+ // read green while one room is still missing the command.
282
+ if (results.some((r) => r.status === "failed")) return { status: "failed", detail };
283
+ if (results.some((r) => r.status === "updated")) return { status: "updated", detail };
284
+ return { status: "unchanged", detail };
285
+ }