@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,469 @@
1
+ // The Discord I/O half of the admin approvals flow โ€” the sibling of flow.ts's Slack
2
+ // wiring, funnelling through the SAME platform-neutral decide core (resolveApprovalClick).
3
+ // Two entry points:
4
+ //
5
+ // postDiscordApprovalCard() โ€” the CardPoster the propose tools inject into
6
+ // requestApproval on a Discord surface: renders the
7
+ // pure card (discord-card.ts) and posts it via the
8
+ // Discord REST create-message API with the bot token.
9
+ // NEVER throws (CardPoster contract) โ€” any failure
10
+ // resolves undefined and requestApproval expires the
11
+ // pending + throws its own explanatory error.
12
+ // postDiscordJudgeLiftNotice() โ€” the JudgeLiftNoticePoster this bundle registers
13
+ // (OPE-1103): the notice for an action the armed judge
14
+ // LIFTED, carrying the one "This should not have run"
15
+ // button. Same REST transport, opposite failure
16
+ // posture โ€” the act has already run, so a failed post
17
+ // is logged and swallowed, never fatal.
18
+ // handleDiscordApprovalInteraction() โ€” called by the Discord front route
19
+ // (channels/discord-front.ts) for every verified
20
+ // interaction body. Returns null for anything that
21
+ // isn't an `oe-approval:` MESSAGE_COMPONENT click
22
+ // (the caller forwards those to Eve โ€” PING, slash
23
+ // commands, Eve's own `eve_input:` HITL buttons);
24
+ // otherwise it OWNS the click end-to-end and returns
25
+ // the interaction RESPONSE body to send. NEVER throws
26
+ // โ€” Discord's interactions endpoint must answer
27
+ // within 3s, so every failure mode is fail-soft.
28
+ //
29
+ // WHY the click path is response-shaped (vs. Slack's fire-and-forget chat.update): a Discord
30
+ // component click is answered by REPLYING to the interaction itself โ€” type 7 UPDATE_MESSAGE
31
+ // rewrites the very message the button lives on (no bot token, no extra REST call), and
32
+ // type 4 + flags 64 is an EPHEMERAL note only the clicker sees (the denial UX Slack fakes
33
+ // with a thread note).
34
+ //
35
+ // ๐Ÿ”ด THE THREE-SECOND BUDGET, AND WHAT THIS DOOR PUTS INSIDE IT (OPE-1407). Discord gives an
36
+ // interactions endpoint THREE SECONDS and does not redeliver an expired token, so an admin whose
37
+ // decision really landed can still see a red "This interaction failed" over it. This door used to
38
+ // spend the whole click inside that budget: `resolveApprovalClick` AND `deliverDecisionFeedback`,
39
+ // which posts a notice through Discord REST and dispatches a decision wake. The FEEDBACK now runs
40
+ // off the response path under the route's `waitUntil` when there is one (`DiscordApprovalDeps`).
41
+ // Nothing in the interaction response depends on it, so it is the leg that should never have been in
42
+ // the budget.
43
+ //
44
+ // ๐Ÿ”ด `resolveApprovalClick` STAYS ON THE RESPONSE PATH, and this door therefore CANNOT copy the
45
+ // grant door's ack-then-decide shape (access/discord-grant-click.ts). That door answers every case
46
+ // with one `type 4` ephemeral, so a `type 5` deferred ephemeral plus a follow-up edit is the same
47
+ // answer arriving later. This door answers with THREE different shapes chosen by the resolution:
48
+ // `type 7` REPLACES the card with the decided render and strips its buttons, `type 6` acks silently
49
+ // and leaves the card alone, `type 4` sends an ephemeral refusal and leaves the row pending. The
50
+ // shape is not known until the decision is made, and Discord fixes a response's KIND at the first
51
+ // response. Deferring as `type 5` would drop the card replacement โ€” the decided card would keep its
52
+ // buttons โ€” and deferring as `type 6` would have no way to send the refusal branches' ephemeral
53
+ // without overwriting the pending card in the channel. Closing that half needs a follow-up MESSAGE
54
+ // (`POST /webhooks/{app}/{token}` with `flags 64`) rather than the `@original` edit this package
55
+ // has, and that behaviour cannot be measured offline. It is a separate change, and OPE-1407's
56
+ // definition-of-done comment records it as open.
57
+
58
+ import {
59
+ resolveApprovalClick,
60
+ deliverDecisionFeedback,
61
+ discordDecisionWakeTarget,
62
+ postDiscordDecisionNotice,
63
+ clickerFloorRefusalNotice,
64
+ judgeContradictionNotice,
65
+ type ApprovalClickResolution,
66
+ type DecisionWakeDeps,
67
+ type ApprovalOutcome,
68
+ type PendingActionRow,
69
+ } from "@open-everywhere/core/agent/adapter-api.js";
70
+ import {
71
+ OE_DISCORD_APPROVAL_PREFIX,
72
+ parseDiscordApprovalCustomId,
73
+ pendingDiscordCard,
74
+ decidedDiscordCard,
75
+ judgeLiftNoticeDiscordCard,
76
+ isGuildAdministrator,
77
+ scopeWithinGuild,
78
+ type DiscordCardMessage,
79
+ } from "./discord-card.js";
80
+ import { guildIdFromScopeKey, isGuildAdminViaRest } from "./discord-admin.js";
81
+
82
+ function log(msg: string): void {
83
+ console.log(`[open-everywhere:discord-approvals] ${msg}`);
84
+ }
85
+
86
+ // Discord interaction-response wire constants. Defined locally: eve's exported
87
+ // DISCORD_INTERACTION_RESPONSE_TYPE enum has no UPDATE_MESSAGE (7) โ€” its HITL path only
88
+ // ever defers โ€” so importing it would still leave the one we need most as a bare literal.
89
+ const RESPONSE_CHANNEL_MESSAGE = 4; // CHANNEL_MESSAGE_WITH_SOURCE โ€” a fresh reply to the click
90
+ const RESPONSE_DEFERRED_UPDATE = 6; // DEFERRED_UPDATE_MESSAGE โ€” ack, leave the message as-is
91
+ const RESPONSE_UPDATE_MESSAGE = 7; // UPDATE_MESSAGE โ€” replace the message the button lives on
92
+ const EPHEMERAL_FLAG = 64; // only the clicking user sees the reply
93
+
94
+ /** One Discord interaction response body (`{ type, data? }`), ready to JSON out. */
95
+ export type DiscordInteractionResponse = Record<string, unknown>;
96
+
97
+ /** Injectable seams so the poster is unit-tested without a live token or network. */
98
+ export interface DiscordPostDeps {
99
+ readonly fetchFn?: typeof fetch;
100
+ readonly token?: string;
101
+ }
102
+
103
+ /**
104
+ * Render + post the approval card into `row.channelId` via Discord REST create-message
105
+ * (`POST /channels/{id}/messages` with `content` + `components`). Resolves to the posted
106
+ * message's id, or undefined on ANY failure โ€” no token, an over-long custom_id (render
107
+ * throws), a non-ok status, a network error, or a response missing an id. NEVER throws,
108
+ * and the bot token is a SECRET โ€” it rides the header, never a log line (both conventions
109
+ * lifted verbatim from channels/discord-gateway.ts's discordCreateMessage).
110
+ */
111
+ export async function postDiscordApprovalCard(
112
+ row: PendingActionRow,
113
+ summaryLines: readonly string[],
114
+ deps: DiscordPostDeps = {},
115
+ ): Promise<string | undefined> {
116
+ return postDiscordCardMessage(row, deps, "approval card", () => pendingDiscordCard(row, summaryLines));
117
+ }
118
+
119
+ /**
120
+ * THE JUDGE-LIFT NOTICE POSTER (OPE-1103) โ€” the `JudgeLiftNoticePoster` this bundle registers, and
121
+ * the Discord half of what was a Slack-only surface.
122
+ *
123
+ * It is NOT the CardPoster above with a different render, and the two must stay apart: that one
124
+ * posts the PENDING card for a LIVE row, and `requestApproval` treats its failure as fatal (it
125
+ * expires the pending and throws, because a proposal nobody can see must never linger). Here the
126
+ * frozen args have ALREADY RUN. A thrown error could not undo them and would only turn a completed
127
+ * action into a failed tool call, so this resolves `void` on every path โ€” the core's own contract
128
+ * for the hook.
129
+ *
130
+ * WHAT A FAILURE COSTS, stated plainly: the room loses its only trace of the lift and the one button
131
+ * that answers it. The audit rows (`approval.autoapproved`, `approval.judge.verdict`) still land, so
132
+ * the event stays queryable โ€” but nobody in the room is told. That is why the failure is LOGGED
133
+ * rather than swallowed silently.
134
+ */
135
+ export async function postDiscordJudgeLiftNotice(
136
+ row: PendingActionRow,
137
+ summaryLines: readonly string[],
138
+ outcomeLine: string,
139
+ deps: DiscordPostDeps = {},
140
+ ): Promise<void> {
141
+ await postDiscordCardMessage(row, deps, "judge-lift notice", () => judgeLiftNoticeDiscordCard(row, summaryLines, outcomeLine));
142
+ }
143
+
144
+ /**
145
+ * Render + POST one code-rendered message into `row.channelId` via Discord REST create-message
146
+ * (`POST /channels/{id}/messages` with `content` + `components`), and hand back the posted message's
147
+ * id โ€” or undefined on ANY failure: no token, no channel, an over-cap custom_id (the render throws),
148
+ * a non-ok status, a network error, or a response missing an id. NEVER throws.
149
+ *
150
+ * `render` is a THUNK, not a rendered message, so an over-cap custom_id is caught by this function's
151
+ * own try rather than at each call site. `what` names the message in the log line only.
152
+ *
153
+ * The bot token is a SECRET โ€” it rides the header, never a log line (both conventions lifted
154
+ * verbatim from channels/discord-gateway.ts's discordCreateMessage).
155
+ */
156
+ async function postDiscordCardMessage(
157
+ row: PendingActionRow,
158
+ deps: DiscordPostDeps,
159
+ what: string,
160
+ render: () => DiscordCardMessage,
161
+ ): Promise<string | undefined> {
162
+ const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
163
+ if (!token) {
164
+ log(`DISCORD_BOT_TOKEN is not set โ€” cannot post the ${what} to channel ${row.channelId}`);
165
+ return undefined;
166
+ }
167
+ if (!row.channelId) {
168
+ log(`no channel id on the pending row โ€” nothing to post the ${what} to`);
169
+ return undefined;
170
+ }
171
+ const doFetch = deps.fetchFn ?? fetch;
172
+ try {
173
+ const card = render(); // throws on an over-cap custom_id โ€” caught below
174
+ const res = await doFetch(`https://discord.com/api/v10/channels/${row.channelId}/messages`, {
175
+ method: "POST",
176
+ headers: { authorization: `Bot ${token}`, "content-type": "application/json" },
177
+ body: JSON.stringify({ content: card.content, components: card.components }),
178
+ });
179
+ if (!res.ok) {
180
+ log(`Discord create-message failed (${res.status}) posting the ${what} to channel ${row.channelId}`);
181
+ return undefined;
182
+ }
183
+ const body = (await res.json()) as unknown;
184
+ const id = body && typeof body === "object" ? (body as Record<string, unknown>)["id"] : undefined;
185
+ return typeof id === "string" && id.length > 0 ? id : undefined;
186
+ } catch (err) {
187
+ log(`Discord ${what} post error for channel ${row.channelId}: ${(err as Error)?.message ?? String(err)}`);
188
+ return undefined;
189
+ }
190
+ }
191
+
192
+ /** The subset of a MESSAGE_COMPONENT interaction the approval click path reads. This is
193
+ * untrusted wire JSON reaching a public URL (post-signature-check, but Discord relays
194
+ * whatever a client sends) โ€” every field is probed, nothing is assumed (same defensive
195
+ * posture as discord-gateway.ts's parseRelayPayload). */
196
+ export interface DiscordApprovalClick {
197
+ readonly customId: string;
198
+ /** The clicking user's id โ€” `member.user.id` in a guild, top-level `user.id` in a DM. */
199
+ readonly clickerId?: string;
200
+ readonly guildId?: string;
201
+ /** `member.permissions` โ€” Discord's stringified permission bitfield for the clicker in
202
+ * the channel the click happened in. Absent outside guilds. */
203
+ readonly memberPermissions?: string;
204
+ }
205
+
206
+ function isObject(v: unknown): v is Record<string, unknown> {
207
+ return typeof v === "object" && v !== null;
208
+ }
209
+ function str(v: unknown): string | undefined {
210
+ return typeof v === "string" && v.length > 0 ? v : undefined;
211
+ }
212
+
213
+ /**
214
+ * Probe a raw interaction body for an `oe-approval:` MESSAGE_COMPONENT click. Returns null
215
+ * for anything else โ€” wrong type, no custom_id, or a foreign prefix โ€” which the caller
216
+ * treats as "not ours โ†’ forward to Eve". A click that IS ours but is missing its clicker
217
+ * still returns (with `clickerId` undefined) so the handler can fail soft ON the click
218
+ * rather than forward a half-understood payload to Eve (mirrors handleApprovalPayload's
219
+ * malformed-payload branch). NEVER throws.
220
+ */
221
+ export function parseDiscordComponentClick(raw: unknown): DiscordApprovalClick | null {
222
+ if (!isObject(raw) || raw["type"] !== 3) return null; // not a MESSAGE_COMPONENT interaction
223
+ const data = isObject(raw["data"]) ? raw["data"] : undefined;
224
+ const customId = data ? str(data["custom_id"]) : undefined;
225
+ if (!customId || !customId.startsWith(OE_DISCORD_APPROVAL_PREFIX)) return null; // not ours โ€” forward
226
+ const member = isObject(raw["member"]) ? raw["member"] : undefined;
227
+ const memberUser = member && isObject(member["user"]) ? member["user"] : undefined;
228
+ const user = isObject(raw["user"]) ? raw["user"] : undefined;
229
+ return {
230
+ customId,
231
+ clickerId: (memberUser ? str(memberUser["id"]) : undefined) ?? (user ? str(user["id"]) : undefined),
232
+ guildId: str(raw["guild_id"]),
233
+ memberPermissions: member ? str(member["permissions"]) : undefined,
234
+ };
235
+ }
236
+
237
+ function ephemeral(content: string): DiscordInteractionResponse {
238
+ return { type: RESPONSE_CHANNEL_MESSAGE, data: { content, flags: EPHEMERAL_FLAG } };
239
+ }
240
+
241
+ /** Test seams for the decide FEEDBACK (task #106) โ€” absent โ‡’ the live poster/dispatch. */
242
+ export interface DiscordApprovalDeps {
243
+ /** Notice poster โ€” defaults to {@link postDiscordDecisionNotice} (bot-token create-message). */
244
+ readonly postNotice?: (row: PendingActionRow, text: string) => Promise<unknown>;
245
+ readonly decisionWake?: DecisionWakeDeps;
246
+ /**
247
+ * The route's own `waitUntil` (OPE-1407). PRESENT โ‡’ the decision FEEDBACK runs off the response
248
+ * path and the interaction is answered as soon as the decision itself is made. ABSENT โ‡’ the
249
+ * feedback is awaited first, the pre-OPE-1407 shape โ€” correct, and slower by a Discord REST post
250
+ * plus a wake dispatch.
251
+ *
252
+ * ๐Ÿ”ด TWO LEGS MOVE, AND THE SECOND ONE EXECUTES. The decision FEEDBACK above, and the core's
253
+ * OPE-251 session-grant RELEASE (OPE-1709), which this file forwards into `resolveApprovalClick`
254
+ * below โ€” an "Approve for session" click decides and runs up to `MAX_SESSION_GRANT_RELEASES`
255
+ * sibling cards, so it is the heavier of the two. Do not read this hook as feedback plumbing.
256
+ *
257
+ * ๐Ÿ”ด WHAT DOES NOT MOVE IS THE RESPONSE BODY. `resolveApprovalClick` decides it, so that CALL
258
+ * cannot be deferred without answering before the answer is known โ€” see the header. Handing it the
259
+ * hook defers work it starts, never the resolution it returns.
260
+ */
261
+ readonly waitUntil?: (p: Promise<unknown>) => void;
262
+ }
263
+
264
+ /**
265
+ * Map a decide resolution to the interaction response โ€” the Discord rendering of exactly
266
+ * what handleApprovalPayload renders as Slack calls. Pure โ†’ unit-tested as a matrix.
267
+ * decided / expired โ†’ type 7: REPLACE the card with the decided render (components: [],
268
+ * so the buttons are gone and the card can't be re-clicked โ€” the CAS
269
+ * already protects correctness; this is the UX half).
270
+ * denied โ†’ type 4 ephemeral: the row stays pending (a real admin can still act
271
+ * on the same card), the clicker alone is told why.
272
+ * unknown โ†’ type 4 ephemeral: same wording as the Slack card-text fallback.
273
+ * everything else โ†’ type 6: silent ack, leave the message alone (a stale re-click /
274
+ * lost race / unregistered kind โ€” same silent-by-design branches as
275
+ * the Slack path; re-rendering could clobber the winner's card).
276
+ */
277
+ export function discordResponseFor(res: ApprovalClickResolution): DiscordInteractionResponse {
278
+ switch (res.outcome) {
279
+ case "unknown":
280
+ return ephemeral("This approval request is no longer known (it may have already been decided or has aged out).");
281
+ case "not-pending":
282
+ case "expired-race":
283
+ case "unregistered-kind":
284
+ case "lost-race":
285
+ return { type: RESPONSE_DEFERRED_UPDATE };
286
+ case "expired": {
287
+ const card = decidedDiscordCard(res.row, res.summaryLines, "expired");
288
+ return { type: RESPONSE_UPDATE_MESSAGE, data: { content: card.content, components: card.components } };
289
+ }
290
+ case "denied":
291
+ return ephemeral("Only a domain/org admin โ€” or a server administrator, for this server's own scopes โ€” can decide this request.");
292
+ case "clicker-denied":
293
+ // The clicker gate (OPE-185, core approvals/clicker-floor.ts) โ€” same UX as `denied`: an
294
+ // ephemeral to the clicker alone, card untouched, row still pending.
295
+ //
296
+ // โš ๏ธ NOT REACHABLE FROM THIS SURFACE TODAY, and it is honest to say so rather than let the
297
+ // branch imply Discord enforces something it doesn't. The gate runs only for a caller that
298
+ // supplies a `clickContext`, and `handleDiscordApprovalInteraction` below deliberately
299
+ // supplies none: Discord has no member supplier (the privileged GUILD_MEMBERS intent, and no
300
+ // guest concept to map onto `hasGuest` even then), so every leg would be inert anyway and
301
+ // Discord clicks stay byte-identical to pre-OPE-185. The case exists because the resolution
302
+ // union is SHARED across surfaces, and this switch must stay exhaustive over it.
303
+ return ephemeral(clickerFloorRefusalNotice(res.reason));
304
+ case "halted":
305
+ // The break-glass halt arrow at the click (OPE-1604, core approvals/flow.ts) โ€” same UX as
306
+ // `denied`: an ephemeral to the clicker alone, message untouched, row still pending, so the
307
+ // same card works again once an admin runs `resume` โ€” until the TTL sweep reaps it, which a
308
+ // halt does not stop (see the `halted` resolution's own doc). REACHABLE from this surface: unlike the
309
+ // clicker gate above, this arrow needs no `clickContext` and runs for every click path.
310
+ // `res.notice` is core's ONE halt renderer (access/halt.ts `haltRefusalMessage`), so a
311
+ // Discord room and a Slack room read the same sentence โ€” including the fail-closed clause
312
+ // when the halt state could not be read at all.
313
+ return ephemeral(res.notice);
314
+ // NB: this renderer stays PURE โ€” the decide/expire decision FEEDBACK (fresh notice beside the
315
+ // card + window wake, task #106 โ€” an UPDATE_MESSAGE edit is invisible to backscroll, exactly
316
+ // like Slack's chat.update) is handleDiscordApprovalInteraction's side-effect leg, not a
317
+ // response concern.
318
+ case "contradicted":
319
+ // The judge-contradiction overrule (OPE-965; reachable on this surface since OPE-1103, when
320
+ // `postDiscordJudgeLiftNotice` above started rendering the button). An ephemeral to the
321
+ // clicker, message UNTOUCHED โ€” deliberately unlike `decided`, which strips its buttons: the
322
+ // notice reports an act that really did happen and stays in the room as its trace, and a
323
+ // second click is a defined no-op the copy answers ("already flagged, it still counts once").
324
+ // `judgeContradictionNotice` is core's ONE renderer for all four outcomes, so this room and a
325
+ // Slack room are told the same thing about the same overrule.
326
+ return ephemeral(judgeContradictionNotice(res.contradiction));
327
+ case "decided": {
328
+ const card = decidedDiscordCard(res.row, res.summaryLines, res.outcomeLine);
329
+ return { type: RESPONSE_UPDATE_MESSAGE, data: { content: card.content, components: card.components } };
330
+ }
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Handle one verified Discord interaction body. Returns null for anything that isn't an
336
+ * `oe-approval:` component click (caller forwards to Eve); otherwise owns the click and
337
+ * returns the interaction response to send. The approver policy on this surface is
338
+ * two-legged, fail-closed:
339
+ * - the config-as-code roster (clickerMayDecide inside the core โ€” org admins, or the
340
+ * FROZEN row domain's admins; a Discord clicker is matched as the PLATFORM-QUALIFIED key
341
+ * `discord/<user-id>`, which is why this call states its platform rather than letting the
342
+ * core infer one โ€” see `clickerPlatform` below), OR
343
+ * - Discord-native standing: the clicker's member.permissions carries ADMINISTRATOR
344
+ * (missing/unparseable โ†’ not an admin) AND the frozen row's scope is inside THIS guild
345
+ * (scopeWithinGuild โ€” a guild admin is authority over their own server's scopes only).
346
+ * NEVER throws โ€” an unexpected failure resolves to an ephemeral note, because the
347
+ * interactions endpoint must always answer.
348
+ */
349
+ export async function handleDiscordApprovalInteraction(raw: unknown, deps: DiscordApprovalDeps = {}): Promise<DiscordInteractionResponse | null> {
350
+ const click = parseDiscordComponentClick(raw);
351
+ if (!click) return null; // not ours โ€” the front route forwards to Eve
352
+
353
+ try {
354
+ const parsed = parseDiscordApprovalCustomId(click.customId);
355
+ if (!parsed) return null; // ours-prefixed but unparseable โ†’ forward (mirrors parseApprovalAction's null)
356
+
357
+ if (!click.clickerId) {
358
+ // It WAS ours; fail soft on the click rather than forward a half-understood payload.
359
+ console.error(`[open-everywhere:discord-approvals] component click "${click.customId}" carries no user id โ€” ignoring the click`);
360
+ return ephemeral("Couldn't read who clicked โ€” this decision was not applied.");
361
+ }
362
+ const clickerId = click.clickerId;
363
+
364
+ const res = await resolveApprovalClick({
365
+ org: parsed.org,
366
+ id: parsed.id,
367
+ decision: parsed.decision,
368
+ clickerId,
369
+ // The clicker id is a Discord snowflake, so the roster arm matches it as
370
+ // `discord/<id>` (identity/principal-key.ts). Stated here rather than inferred: this
371
+ // surface passes no `clickContext`, and a DM-context click has no room to read a platform
372
+ // off at all โ€” the leg that most needs the roster is exactly the one with no other source.
373
+ clickerPlatform: "discord",
374
+ // ๐Ÿ”ด THE ROUTE'S `waitUntil` REACHES THE CORE TOO (OPE-1709). The decision FEEDBACK below
375
+ // already rides it; this hands the core the same hook for the OPE-251 session-grant RELEASE,
376
+ // which decides and EXECUTES the sibling cards the grant covers. Both legs are work nothing in
377
+ // the interaction response depends on, so both belong off the three-second budget. Absent โ‡’
378
+ // the core awaits the release, the same "no waitUntil โ‡’ no defer" rule this file's feedback
379
+ // leg states below.
380
+ ...(deps.waitUntil ? { waitUntil: deps.waitUntil } : {}),
381
+ // Discord-native admin leg โ€” "administrator of the guild the FROZEN row's scope lives
382
+ // in", two evidence paths by where the click happened:
383
+ // in a guild โ†’ the payload's member.permissions is authoritative for THAT guild, so
384
+ // it must also BE the row's guild (scopeWithinGuild) โ€” a guild-A admin clicking a
385
+ // card in guild A for a row aimed at guild B proves nothing about B.
386
+ // in a DM โ†’ no member payload exists at all, so ask Discord directly: REST-check
387
+ // the clicker's standing in the row's own guild (isGuildAdminViaRest, fail-closed;
388
+ // a non-guild scope โ€” Slack, a DM bucket โ€” resolves no guild and denies). This is
389
+ // what makes the over-budget escape hatch work: the โ›”'d channel drops every turn,
390
+ // the proposal happens in a DM, and the card lands there โ€” the admin must be able
391
+ // to decide it where it is.
392
+ platformAdmin: async (row) => {
393
+ if (click.guildId) return isGuildAdministrator(click.memberPermissions) && scopeWithinGuild(row.scopeKey, click.guildId);
394
+ const rowGuild = guildIdFromScopeKey(row.scopeKey);
395
+ return rowGuild ? isGuildAdminViaRest(rowGuild, clickerId) : false;
396
+ },
397
+ });
398
+ // Decision FEEDBACK (task #106 โ€” Slack click-path parity): a fresh notice beside the card (the
399
+ // UPDATE_MESSAGE response is an EDIT, invisible to backscroll) + the cross-thread window wake.
400
+ // The wake's guild id comes from the SIGNED interaction (Slack's team.id analogue โ€” the frozen
401
+ // scopeKey can name a different workspace under an explicit-scope override); a DM-context
402
+ // interaction has none โ†’ no wake, fail-soft, the notice still posts beside the card. No transport
403
+ // failure throws out of it; the egress spoof's Vercel refusal can (OPE-1132). On the AWAITED path
404
+ // below that refusal reaches the caller's own catch, which answers the interaction anyway rather
405
+ // than leaving Discord to retry. On the DEFERRED path the response has already gone, so the catch
406
+ // around the deferred promise logs it instead and the door's answer is unaffected.
407
+ //
408
+ // ๐Ÿ”ด IT RUNS OFF THE RESPONSE PATH WHEN THE ROUTE GAVE US A `waitUntil` (OPE-1407). It is a
409
+ // Discord REST post plus a wake dispatch, and nothing in the interaction response depends on it,
410
+ // so it is exactly the leg that should not sit inside a three-second budget. See the header.
411
+ const feedback = (): Promise<void> | undefined => {
412
+ if (res.outcome === "expired") {
413
+ return deliverDecisionFeedback(
414
+ {
415
+ row: res.row,
416
+ summaryLines: res.summaryLines,
417
+ outcome: "expired",
418
+ outcomeLine: "expired",
419
+ executed: "none",
420
+ target: discordDecisionWakeTarget(res.row, click.guildId),
421
+ postNotice: (text) => (deps.postNotice ?? postDiscordDecisionNotice)(res.row, text),
422
+ },
423
+ deps.decisionWake,
424
+ );
425
+ }
426
+ if (res.outcome === "decided") {
427
+ return deliverDecisionFeedback(
428
+ {
429
+ row: res.row,
430
+ summaryLines: res.summaryLines,
431
+ outcome: res.row.status as ApprovalOutcome,
432
+ outcomeLine: res.outcomeLine,
433
+ executed: res.executed,
434
+ clickerId,
435
+ target: discordDecisionWakeTarget(res.row, click.guildId),
436
+ postNotice: (text) => (deps.postNotice ?? postDiscordDecisionNotice)(res.row, text),
437
+ },
438
+ deps.decisionWake,
439
+ );
440
+ }
441
+ return undefined;
442
+ };
443
+
444
+ const pending = feedback();
445
+ if (pending !== undefined) {
446
+ if (deps.waitUntil === undefined) {
447
+ // No place to run it past the reply: await, the pre-OPE-1407 shape. On a serverless host a
448
+ // promise left running after the response is frozen with the invocation, so a defer with
449
+ // nowhere to live would DROP the notice โ€” strictly worse than answering slowly.
450
+ await pending;
451
+ } else {
452
+ // TOTAL. This runs after the response has gone; a throw here reaches the host's unhandled
453
+ // rejection handler and tells nobody anything. The decision itself has already landed and is
454
+ // audited, so what a failure costs is the notice and the wake โ€” logged, never fatal.
455
+ deps.waitUntil(
456
+ pending.catch((err: unknown) => {
457
+ log(`deferred decision feedback failed for "${click.customId}": ${(err as Error)?.message ?? String(err)}`);
458
+ }),
459
+ );
460
+ }
461
+ }
462
+ return discordResponseFor(res);
463
+ } catch (err) {
464
+ // Genuinely unexpected (store outage, โ€ฆ) after we'd established this WAS our click โ€”
465
+ // fail soft with an honest ephemeral note; never throw out of the interactions path.
466
+ console.error(`[open-everywhere:discord-approvals] click handling failed softly for "${click.customId}": ${String(err)}`);
467
+ return ephemeral("Something went wrong handling this click โ€” the decision was not applied.");
468
+ }
469
+ }
@@ -0,0 +1,27 @@
1
+ // The Discord scope-label resolver โ€” EXTRACTED from core approvals/scope-label.ts (OPE-114 stage 4).
2
+ // Friendly DISPLAY labels for an approval-card scope key, registered via registerScopeLabelResolver
3
+ // from registerDiscordProvider(). DISPLAY ONLY, never an access/execution input; fail-open (any
4
+ // lookup failure โ†’ undefined โ‡’ the card renders the raw key). Uses the shared discord-channel-info
5
+ // `GET /channels|guilds/{id}` fetch (moved into this bundle).
6
+ //
7
+ // discord/<guild> โ†’ `the <guild name> server`
8
+ // discord/<guild>/<channel> โ†’ `#<channel name>`
9
+ // discord/<guild>/<thread> โ†’ `#<parent name> ยท thread "<thread name>"` (anchor on the parent โ€”
10
+ // an auto-thread's name is derived from the triggering message, gibberish as a scope label).
11
+
12
+ import type { ScopeLabelResolver } from "@open-everywhere/core/agent/adapter-api.js";
13
+ import { fetchDiscordInfo } from "../channels/discord-channel-info.js";
14
+
15
+ export const discordScopeLabelResolver: ScopeLabelResolver = async ({ workspace, surface }) => {
16
+ if (surface) {
17
+ const info = await fetchDiscordInfo("channels", surface);
18
+ if (!info) return undefined;
19
+ if (info.parentId) {
20
+ const parent = await fetchDiscordInfo("channels", info.parentId);
21
+ if (parent?.name) return `#${parent.name} ยท thread "${info.name}"`;
22
+ }
23
+ return info.isThread ? `thread "${info.name}"` : `#${info.name}`;
24
+ }
25
+ const info = await fetchDiscordInfo("guilds", workspace);
26
+ return info?.name ? `the ${info.name} server` : undefined;
27
+ };