@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,227 @@
1
+ // Discord guild STANDING via REST — admin ({@link isGuildAdminViaRest}) and plain membership
2
+ // ({@link isGuildMemberViaRest}).
3
+ //
4
+ // ADMIN — for the two places a click/message payload
5
+ // CANNOT answer "is this user an administrator of that guild":
6
+ // - an approval-card click in a DM (Discord DM interactions carry no `member`, so
7
+ // the payload leg in discord-flow.ts is structurally dead there — and the DM is
8
+ // the documented escape hatch for an over-budget channel, so it MUST be decidable)
9
+ // - the spend-cap admin bypass on the ambient gateway (a relay MESSAGE_CREATE has no
10
+ // computed permissions field)
11
+ // In both cases the guild is known from OUR OWN data (a frozen scopeKey / a partition
12
+ // surfaceKey), never from the untrusted payload.
13
+ //
14
+ // MEMBERSHIP — the approval clicker-floor gate (core access/native-admin.ts, OPE-303). Core's gate
15
+ // used to end at `platform === "slack"` on the belief that "Discord has no per-user membership
16
+ // machinery"; the member lookup below was already here, doing half the admin check's work, and its
17
+ // 404 IS the not-a-member answer. Registered through core's `registerNativeMemberCheck` seam so the
18
+ // vendor call stays in this bundle.
19
+ //
20
+ // FAIL-CLOSED throughout: both answer an AUTHORITY question, so any failure — no
21
+ // token, unknown guild (including a DM-bucket "discord/<userId>" pseudo-workspace,
22
+ // whose guild lookup 404s), missing member, network error, malformed body — is the
23
+ // NEGATIVE verdict ("not an admin" / "not a member"), never a guess. The bot token is
24
+ // a SECRET — header only, never logged.
25
+
26
+ import { isGuildAdministrator } from "./discord-card.js";
27
+
28
+ function log(msg: string): void {
29
+ console.log(`[open-everywhere:discord-admin] ${msg}`);
30
+ }
31
+
32
+ /**
33
+ * The guild segment of a Discord scope/surface key — `discord/<guild>` or
34
+ * `discord/<guild>/<surface>` → `<guild>`; anything else (other platforms, malformed)
35
+ * → undefined. NOTE: a Discord DM binds workspace to the USER id (identity/binding.ts),
36
+ * so a DM-scoped key parses "successfully" here — the REST lookup then fails (it isn't
37
+ * a guild) and the check stays fail-closed. Pure.
38
+ */
39
+ export function guildIdFromScopeKey(scopeKey: string): string | undefined {
40
+ const parts = scopeKey.split("/");
41
+ return parts[0] === "discord" && parts[1] ? parts[1] : undefined;
42
+ }
43
+
44
+ /** Injectable seams so the check is unit-tested without a live token or network. */
45
+ export interface DiscordAdminDeps {
46
+ readonly fetchFn?: typeof fetch;
47
+ readonly token?: string;
48
+ readonly nowMs?: number;
49
+ }
50
+
51
+ function isObject(v: unknown): v is Record<string, unknown> {
52
+ return typeof v === "object" && v !== null;
53
+ }
54
+
55
+ // Verdicts (positive AND negative) are cached briefly: the gateway bypass check can run
56
+ // on every message in an over-budget engaged channel, and two REST calls per message is
57
+ // silly for an answer that changes on role edits, not messages. 60s keeps a demotion
58
+ // honest-enough while absorbing message bursts. In-process — a cold serverless instance
59
+ // just pays the two calls once.
60
+ const verdictCache = new Map<string, { verdict: boolean; at: number }>();
61
+ const VERDICT_TTL_MS = 60_000;
62
+ /** Membership verdicts cache separately from admin ones — same key space, different question, and
63
+ * collapsing them would let "is a member" satisfy a caller that asked "is an admin". */
64
+ const memberVerdictCache = new Map<string, { verdict: boolean; at: number }>();
65
+
66
+ /**
67
+ * Is `userId` an administrator of `guildId`, per Discord itself? True when they OWN the
68
+ * guild (`guild.owner_id`) or hold any role whose permissions carry ADMINISTRATOR (the
69
+ * same bit test the click-payload leg uses — isGuildAdministrator). Two REST calls:
70
+ * `GET /guilds/{id}` (owner + the role table) and `GET /guilds/{id}/members/{userId}`
71
+ * (the user's role ids). NEVER throws; every failure is `false` (see the file header).
72
+ */
73
+ export async function isGuildAdminViaRest(guildId: string, userId: string, deps: DiscordAdminDeps = {}): Promise<boolean> {
74
+ const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
75
+ if (!token || !guildId || !userId) return false;
76
+ const cacheKey = `${guildId}:${userId}`;
77
+ const now = deps.nowMs ?? Date.now();
78
+ const hit = verdictCache.get(cacheKey);
79
+ if (hit && now - hit.at < VERDICT_TTL_MS) return hit.verdict;
80
+
81
+ const doFetch = deps.fetchFn ?? fetch;
82
+ const headers = { authorization: `Bot ${token}` };
83
+ const verdict = await (async () => {
84
+ try {
85
+ const guildRes = await doFetch(`https://discord.com/api/v10/guilds/${guildId}`, { headers });
86
+ if (!guildRes.ok) {
87
+ log(`guild lookup failed (${guildRes.status}) for guild ${guildId} — treating as not-admin`);
88
+ return false;
89
+ }
90
+ const guild = (await guildRes.json()) as unknown;
91
+ if (!isObject(guild)) return false;
92
+ if (typeof guild["owner_id"] === "string" && guild["owner_id"] === userId) return true;
93
+ const roleTable = Array.isArray(guild["roles"]) ? (guild["roles"] as unknown[]) : [];
94
+
95
+ const memberRes = await doFetch(`https://discord.com/api/v10/guilds/${guildId}/members/${userId}`, { headers });
96
+ if (!memberRes.ok) {
97
+ // 404 = not a member at all — the common non-admin case, not notable.
98
+ if (memberRes.status !== 404) log(`member lookup failed (${memberRes.status}) for guild ${guildId} — treating as not-admin`);
99
+ return false;
100
+ }
101
+ const member = (await memberRes.json()) as unknown;
102
+ const memberRoleIds = new Set(
103
+ isObject(member) && Array.isArray(member["roles"]) ? (member["roles"] as unknown[]).filter((r): r is string => typeof r === "string") : [],
104
+ );
105
+ return roleTable.some(
106
+ (r) => isObject(r) && typeof r["id"] === "string" && memberRoleIds.has(r["id"]) && isGuildAdministrator(r["permissions"]),
107
+ );
108
+ } catch (err) {
109
+ log(`admin check error for guild ${guildId}: ${(err as Error)?.message ?? String(err)}`);
110
+ return false;
111
+ }
112
+ })();
113
+ verdictCache.set(cacheKey, { verdict, at: now });
114
+ return verdict;
115
+ }
116
+
117
+ /**
118
+ * Is `userId` a MEMBER of `guildId`, per Discord itself? ONE REST call —
119
+ * `GET /guilds/{id}/members/{userId}` — whose 404 is the platform's own "not a member" answer.
120
+ *
121
+ * WHY MEMBERSHIP IS THE HONEST CEILING HERE. Discord has no guest tier: a user is in the guild or
122
+ * they are not, and there is no `is_restricted` analogue to read. So this returns the strongest
123
+ * membership signal the platform HAS, which is what core's {@link NativeMemberCheck} contract asks
124
+ * for — every lesser standing (non-member, unknown user, DM pseudo-workspace) resolves false. The
125
+ * one refinement worth making is the BOT bit: a bot account in the guild is a member by Discord's
126
+ * reckoning and is not a person, and the gate this feeds decides whether an UNGATED memory deletion
127
+ * proceeds, so a bot reads false.
128
+ *
129
+ * FAIL-CLOSED throughout (see the file header): no token, a DM-bucket pseudo-guild, a network
130
+ * error, a malformed body — all "not a member", never a guess. NEVER throws.
131
+ */
132
+ export async function isGuildMemberViaRest(guildId: string, userId: string, deps: DiscordAdminDeps = {}): Promise<boolean> {
133
+ const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
134
+ if (!token || !guildId || !userId) return false;
135
+ const cacheKey = `${guildId}:${userId}`;
136
+ const now = deps.nowMs ?? Date.now();
137
+ const hit = memberVerdictCache.get(cacheKey);
138
+ if (hit && now - hit.at < VERDICT_TTL_MS) return hit.verdict;
139
+
140
+ const doFetch = deps.fetchFn ?? fetch;
141
+ const verdict = await (async () => {
142
+ try {
143
+ const res = await doFetch(`https://discord.com/api/v10/guilds/${guildId}/members/${userId}`, { headers: { authorization: `Bot ${token}` } });
144
+ if (!res.ok) {
145
+ // 404 = not a member at all — the common negative case, not notable.
146
+ if (res.status !== 404) log(`member lookup failed (${res.status}) for guild ${guildId} — treating as not-a-member`);
147
+ return false;
148
+ }
149
+ const member = (await res.json()) as unknown;
150
+ if (!isObject(member)) return false;
151
+ const user = member["user"];
152
+ if (isObject(user) && user["bot"] === true) return false; // a bot is a guild member and not a person
153
+ // A present `user.id` is the proof the body describes the member we asked about; a 200 with a
154
+ // body we cannot read is an answer we did not get.
155
+ return isObject(user) && typeof user["id"] === "string" ? user["id"] === userId : false;
156
+ } catch (err) {
157
+ log(`member check error for guild ${guildId}: ${(err as Error)?.message ?? String(err)}`);
158
+ return false;
159
+ }
160
+ })();
161
+ memberVerdictCache.set(cacheKey, { verdict, at: now });
162
+ return verdict;
163
+ }
164
+
165
+ /** Role-evidence verdicts cache separately from the ADMIN/MEMBERSHIP boolean caches above — a
166
+ * THIRD question (what roles, not "is a member" or "is an admin"), and a boolean cache cannot
167
+ * represent "cannot check" without lying either way a boolean would. */
168
+ const roleEvidenceCache = new Map<string, { roles: readonly string[]; at: number }>();
169
+
170
+ /**
171
+ * What Discord ROLES does `userId` hold in `guildId`, right now? ONE REST call —
172
+ * `GET /guilds/{id}/members/{userId}` — read for its `roles` array (OPE-475, the backscroll
173
+ * author-trust register leg: {@link gradeDiscordContextLines} feeds this INTO
174
+ * `identity/author-trust.ts`'s `{ membership }` pattern, the same group-rung key shape the
175
+ * TRIGGER already builds from the relay's `member.roles`).
176
+ *
177
+ * 🔴 FAIL-OPEN, NOT FAIL-CLOSED — the opposite direction from {@link isGuildMemberViaRest} above,
178
+ * and deliberately so: that gate feeds an AUTHORITY question (does an ungated action proceed?),
179
+ * where "cannot check" safely reads as "no". This feeds a TAINT JOIN, where "cannot check" must
180
+ * NEVER read as "holds no roles" — that would be indistinguishable from a verified empty result
181
+ * and would silently over-mark every wake whose role fetch merely failed (network blip, rate
182
+ * limit, a token missing `GUILD_MEMBERS`). So the three outcomes are kept apart in the return type:
183
+ * - a REAL (possibly empty) roles array — the request succeeded and the body was readable;
184
+ * - `[]` on a verified 404 — Discord's own "not a member" answer, which honestly IS "holds no
185
+ * roles", not a guess;
186
+ * - `undefined` on anything else (no token, network error, non-404 non-2xx, an unreadable body)
187
+ * — "cannot check", and the caller must skip the register query for that author entirely
188
+ * rather than ever pass an unverified `[]`.
189
+ * NEVER throws.
190
+ */
191
+ export async function discordAuthorRoles(guildId: string, userId: string, deps: DiscordAdminDeps = {}): Promise<readonly string[] | undefined> {
192
+ const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
193
+ if (!token || !guildId || !userId) return undefined; // cannot check — never a guess
194
+ const cacheKey = `${guildId}:${userId}`;
195
+ const now = deps.nowMs ?? Date.now();
196
+ const hit = roleEvidenceCache.get(cacheKey);
197
+ if (hit && now - hit.at < VERDICT_TTL_MS) return hit.roles;
198
+
199
+ const doFetch = deps.fetchFn ?? fetch;
200
+ try {
201
+ const res = await doFetch(`https://discord.com/api/v10/guilds/${guildId}/members/${userId}`, { headers: { authorization: `Bot ${token}` } });
202
+ if (res.status === 404) {
203
+ // Verified: this account holds no guild roles because it holds no guild membership. A REAL
204
+ // fact from a real answer, not the unverified `[]` this function exists to replace.
205
+ roleEvidenceCache.set(cacheKey, { roles: [], at: now });
206
+ return [];
207
+ }
208
+ if (!res.ok) {
209
+ log(`role lookup failed (${res.status}) for guild ${guildId} — skipping the register query (cannot check is not checked-and-empty)`);
210
+ return undefined; // cannot check — NEVER cached, NEVER guessed
211
+ }
212
+ const member = (await res.json()) as unknown;
213
+ const rolesField = isObject(member) ? member["roles"] : undefined;
214
+ if (!Array.isArray(rolesField)) return undefined; // unreadable body — skip, don't guess
215
+ const roles = rolesField.filter((r): r is string => typeof r === "string" && r.length > 0);
216
+ roleEvidenceCache.set(cacheKey, { roles, at: now });
217
+ return roles;
218
+ } catch (err) {
219
+ log(`role lookup error for guild ${guildId}: ${(err as Error)?.message ?? String(err)}`);
220
+ return undefined; // cannot check — never a guess
221
+ }
222
+ }
223
+
224
+ /** Reset the cached role-evidence verdicts (tests only). */
225
+ export function _resetDiscordAuthorRolesCache(): void {
226
+ roleEvidenceCache.clear();
227
+ }
@@ -0,0 +1,248 @@
1
+ // Pure Discord renderers + codecs for the admin approval card — the Discord sibling of
2
+ // card.ts (Slack Block Kit). PURE by design, same doctrine: no I/O, no Discord client,
3
+ // nothing async — the card is a CODE-RENDERED VIEW of the FROZEN args, never model text,
4
+ // and purity keeps it trivially unit-testable (discord-approvals-test.ts) without a live
5
+ // bot. The I/O half (REST poster + click handler) lives in discord-flow.ts.
6
+ //
7
+ // Discord's card idiom differs from Slack's in three load-bearing ways, all handled here:
8
+ // - No blocks: the card body is one Markdown `content` string; buttons ride a components
9
+ // ACTION_ROW. (Discord caps `content` at 2000 chars — summary lines are short
10
+ // code-rendered fragments, far under it.)
11
+ // - No per-button `value` slot: Slack carries the org in the button's `value`; Discord's
12
+ // ONLY payload carrier is the `custom_id`, so the org rides there too (same frozen-org
13
+ // doctrine, different envelope). custom_id is capped at 100 chars — enforced below.
14
+ // - Buttons are stripped by REPLACING the message via an interaction response (type 7,
15
+ // UPDATE_MESSAGE), so the decided card here renders with `components: []`.
16
+ //
17
+ // THREE renders live here, and only the first two are a card: the PENDING card (Approve/Reject), the
18
+ // DECIDED card (no buttons), and the JUDGE-LIFT NOTICE (OPE-1103) — a review surface for an action
19
+ // that already ran, carrying the one "This should not have run" button and never a decision one.
20
+
21
+ import type { PendingActionRow } from "@open-everywhere/core/agent/adapter-api.js";
22
+ import { grantButtonLabel, sessionGrantKind } from "@open-everywhere/core/agent/adapter-api.js";
23
+
24
+ /**
25
+ * Every approval button custom_id this feature owns starts with this. Deliberately distinct
26
+ * from BOTH the Slack action_id prefix ("oe_approval:" — a different wire format, keeping
27
+ * grep unambiguous about which surface a log line came from) and Eve's own Discord HITL
28
+ * prefixes ("eve_input:" / "eve_input_freeform:") — the Discord front route classifies
29
+ * "ours vs. forward to Eve" on exactly this prefix (channels/discord-front.ts).
30
+ */
31
+ export const OE_DISCORD_APPROVAL_PREFIX = "oe-approval:";
32
+
33
+ /** Discord's hard cap on a component custom_id. https://discord.com/developers/docs/interactions/message-components */
34
+ export const DISCORD_CUSTOM_ID_MAX = 100;
35
+
36
+ // Discord component/button wire constants (stable public API integers). Defined locally —
37
+ // eve exports DISCORD_COMPONENT_TYPE but importing it would make this pure leaf depend on
38
+ // eve's channel module for two integers; card.ts makes the same leaf-over-import call for
39
+ // the Slack prefix.
40
+ const COMPONENT_ACTION_ROW = 1;
41
+ const COMPONENT_BUTTON = 2;
42
+ const BUTTON_STYLE_SECONDARY = 2; // grey — Approve for session (the grantable-kind middle button, OPE-53/OPE-11)
43
+ const BUTTON_STYLE_SUCCESS = 3; // green — Approve (Slack's `style: "primary"` analogue)
44
+ const BUTTON_STYLE_DANGER = 4; // red — Reject (Slack's `style: "danger"` analogue)
45
+
46
+ /** One Discord message component. Loosely typed on purpose — same spirit as card.ts's
47
+ * SlackBlock: Discord's schema is large and this file only emits two well-known shapes. */
48
+ export interface DiscordComponent {
49
+ readonly type: number;
50
+ readonly [key: string]: unknown;
51
+ }
52
+
53
+ /** A rendered Discord card: Markdown `content` + the components row (empty once decided). */
54
+ export interface DiscordCardMessage {
55
+ readonly content: string;
56
+ readonly components: readonly DiscordComponent[];
57
+ }
58
+
59
+ /**
60
+ * Every verb a Discord approval button can carry. A SUBSET of core's `ApprovalDecision` by
61
+ * construction, not a copy of it: core also parses verbs no Discord button renders. `contradict`
62
+ * (OPE-965/OPE-1103) is the judge-lift notice's one button — it decides nothing, it records an
63
+ * overrule of a verdict whose action has already run.
64
+ */
65
+ export type DiscordApprovalDecision = "approve" | "reject" | "approve_session" | "contradict";
66
+
67
+ /** The closed verb set as a VALUE — one list, so build and parse cannot drift into a button nothing
68
+ * can decode (discord-approvals-test.ts pins the round trip for every entry). */
69
+ const DISCORD_APPROVAL_DECISIONS: readonly DiscordApprovalDecision[] = ["approve", "reject", "approve_session", "contradict"];
70
+
71
+ /**
72
+ * Build one approval button custom_id: `oe-approval:{id}:{verb}:{org}`. The id is
73
+ * our own randomUUID (never contains ":"), the verb is a closed set, and the org rides LAST
74
+ * so it may contain anything (parse peels the two fixed-shape segments off the front).
75
+ * Throws when the result would exceed Discord's 100-char cap — a propose-time fail-loud:
76
+ * the poster (discord-flow.ts) catches it, the post fails, and requestApproval marks the
77
+ * pending expired rather than leaving an unclickable card.
78
+ *
79
+ * `contradict` is 5 chars SHORTER than `approve_session`, so it can never be the verb that first
80
+ * trips the cap — an org that fits a grantable kind's card fits this notice's button too.
81
+ */
82
+ export function buildDiscordApprovalCustomId(row: PendingActionRow, decision: DiscordApprovalDecision): string {
83
+ const customId = `${OE_DISCORD_APPROVAL_PREFIX}${row.id}:${decision}:${row.org}`;
84
+ if (customId.length > DISCORD_CUSTOM_ID_MAX) {
85
+ throw new Error(
86
+ `approval custom_id would be ${customId.length} chars (Discord caps at ${DISCORD_CUSTOM_ID_MAX}) — org "${row.org}" is too long to ride a Discord button`,
87
+ );
88
+ }
89
+ return customId;
90
+ }
91
+
92
+ /**
93
+ * Parse an `oe-approval:{id}:{decision}:{org}` custom_id. Returns null for anything else —
94
+ * including a well-formed-looking id with a garbage decision segment — so callers can treat
95
+ * "not parseable" as "not ours" without a second check (mirrors flow.ts's parseApprovalAction;
96
+ * an unparseable-but-prefixed id forwards to Eve, whose HITL decoder no-ops it harmlessly).
97
+ */
98
+ export function parseDiscordApprovalCustomId(customId: string): { id: string; decision: DiscordApprovalDecision; org: string } | null {
99
+ if (!customId.startsWith(OE_DISCORD_APPROVAL_PREFIX)) return null;
100
+ const rest = customId.slice(OE_DISCORD_APPROVAL_PREFIX.length);
101
+ const first = rest.indexOf(":");
102
+ if (first <= 0) return null; // no id segment
103
+ const second = rest.indexOf(":", first + 1);
104
+ if (second < 0) return null; // no org segment
105
+ const id = rest.slice(0, first);
106
+ const decision = rest.slice(first + 1, second);
107
+ const org = rest.slice(second + 1);
108
+ // "approve_session" (OPE-53) and "contradict" (OPE-1103) are both ADDITIVE — old custom_ids still
109
+ // parse (the id never contains ":"), and a card minted before either verb existed still decodes.
110
+ if (!DISCORD_APPROVAL_DECISIONS.includes(decision as DiscordApprovalDecision) || org.length === 0) return null;
111
+ return { id, decision: decision as DiscordApprovalDecision, org };
112
+ }
113
+
114
+ /** The shared card body: header + summary + provenance. `<@id>` is Discord's user-mention
115
+ * syntax too, so the requester line reads identically to the Slack card. */
116
+ function cardBody(row: PendingActionRow, summaryLines: readonly string[], footer: string): string {
117
+ return [`**Approval requested: ${row.kind}**`, ...summaryLines, footer].join("\n");
118
+ }
119
+
120
+ /**
121
+ * The card for a still-pending action: summary + "requested by / expires" + Approve/Reject
122
+ * buttons carrying the pending id + org in their custom_ids (see the file header for why
123
+ * the org rides the custom_id on Discord). Mirrors card.ts's pendingCardBlocks.
124
+ */
125
+ export function pendingDiscordCard(row: PendingActionRow, summaryLines: readonly string[]): DiscordCardMessage {
126
+ const content = cardBody(row, summaryLines, `-# Requested by <@${row.requestedBy}> · expires ${row.expiresAt}`);
127
+ const buttons: DiscordComponent[] = [
128
+ {
129
+ type: COMPONENT_BUTTON,
130
+ style: BUTTON_STYLE_SUCCESS,
131
+ label: "Approve",
132
+ custom_id: buildDiscordApprovalCustomId(row, "approve"),
133
+ },
134
+ ];
135
+ // OPE-53: a session-grantable row gets the middle "Approve for session" button — publish this one
136
+ // AND skip the card for the rest of THIS proposing session. sessionGrantKind() (core
137
+ // approvals/session-grant.ts) is the ONE predicate, and it grants for `pr.open` ALONE — every
138
+ // other kind returns null and stays two-button per-call. Mirrors card.ts's Slack
139
+ // pendingCardBlocks so the two surfaces agree on the third button.
140
+ if (sessionGrantKind(row) !== null) {
141
+ buttons.push({
142
+ type: COMPONENT_BUTTON,
143
+ style: BUTTON_STYLE_SECONDARY,
144
+ // OPE-152: the label comes from core, so Slack and Discord can never disagree about whether
145
+ // the click grants for this session or standing-for-this-routine (a fire's card).
146
+ label: grantButtonLabel(row),
147
+ custom_id: buildDiscordApprovalCustomId(row, "approve_session"),
148
+ });
149
+ }
150
+ buttons.push({
151
+ type: COMPONENT_BUTTON,
152
+ style: BUTTON_STYLE_DANGER,
153
+ label: "Reject",
154
+ custom_id: buildDiscordApprovalCustomId(row, "reject"),
155
+ });
156
+ return { content, components: [{ type: COMPONENT_ACTION_ROW, components: buttons }] };
157
+ }
158
+
159
+ /**
160
+ * THE JUDGE-LIFT NOTICE (OPE-965 on Slack, OPE-1103 here) — the Discord sibling of card.ts's
161
+ * `judgeLiftNoticeBlocks`, and the room's only trace of an action NOBODY was asked about.
162
+ *
163
+ * WHY IT EXISTS. When the armed judge lifts a gated verb, the approver chain executes the frozen
164
+ * args and posts no card — that is the whole point of the lift. The wedge row and the
165
+ * `approval.judge.verdict` row still land, but the ROOM sees nothing. This notice is that trace, and
166
+ * its single button is the answer: "This should not have run" writes the
167
+ * `approval.judge.contradicted` row the contradiction breaker counts.
168
+ *
169
+ * NOT AN APPROVAL CARD, and the render says so: no Approve, no Reject, no expiry line. The act is
170
+ * already done, so this is a review surface, not a decision surface. It deliberately does NOT reuse
171
+ * `cardBody` — that helper's header reads "Approval requested", which is the one thing this message
172
+ * must never say.
173
+ *
174
+ * THE COPY IS THE SLACK COPY, word for word (the header, the ⚡ line, the button label). Two rooms
175
+ * on two platforms must not be told different things about the same mechanism; only the envelope
176
+ * differs — Markdown `content` + a components row, rather than blocks.
177
+ */
178
+ export function judgeLiftNoticeDiscordCard(row: PendingActionRow, summaryLines: readonly string[], outcomeLine: string): DiscordCardMessage {
179
+ const context = `-# ⚡ Auto-approved by the approval judge — no card was posted and this has already run · ${outcomeLine}`;
180
+ return {
181
+ content: [`**Ran without asking: ${row.kind}**`, ...summaryLines, context].join("\n"),
182
+ components: [
183
+ {
184
+ type: COMPONENT_ACTION_ROW,
185
+ components: [
186
+ {
187
+ type: COMPONENT_BUTTON,
188
+ style: BUTTON_STYLE_DANGER,
189
+ label: "This should not have run",
190
+ custom_id: buildDiscordApprovalCustomId(row, "contradict"),
191
+ },
192
+ ],
193
+ },
194
+ ],
195
+ };
196
+ }
197
+
198
+ /**
199
+ * The card once a decision has landed: SAME summary, buttons GONE (`components: []` — the
200
+ * type-7 UPDATE_MESSAGE response replaces the whole message, so an empty row strips them),
201
+ * plus a context line naming who decided + the outcome. Renders whatever the caller already
202
+ * CAS-decided — this function never decides anything (mirrors card.ts's decidedCardBlocks,
203
+ * including the same three status lines).
204
+ */
205
+ export function decidedDiscordCard(row: PendingActionRow, summaryLines: readonly string[], outcomeLine: string): DiscordCardMessage {
206
+ const decidedContext =
207
+ row.status === "approved"
208
+ ? `✅ Approved by <@${row.decidedBy}> · ${outcomeLine}`
209
+ : row.status === "rejected"
210
+ ? // A kind whose DECLINE is itself durable returns a line saying what the decline recorded
211
+ // (approvals/types.ts ActionKind.declined). Every other kind's reject passes the literal
212
+ // "rejected" and renders byte-for-byte what it always did. Kept in step with card.ts's
213
+ // decidedCardBlocks by hand, exactly like the three status lines above it — a Discord card
214
+ // that dropped the line would leave a clicker believing a decline was remembered.
215
+ `❌ Rejected by <@${row.decidedBy}>${outcomeLine === "rejected" ? "" : ` · ${outcomeLine}`}`
216
+ : "⏰ Expired";
217
+ return { content: cardBody(row, summaryLines, decidedContext), components: [] };
218
+ }
219
+
220
+ /** Discord's ADMINISTRATOR permission bit (0x8). https://discord.com/developers/docs/topics/permissions */
221
+ const ADMINISTRATOR_BIT = 1n << 3n;
222
+
223
+ /**
224
+ * Does a Discord interaction's `member.permissions` bitfield carry ADMINISTRATOR? The field
225
+ * is a STRINGIFIED integer (Discord serializes the 64-bit+ field as a decimal string).
226
+ * FAIL-CLOSED on anything else: missing, empty, non-numeric, or non-string input is "not an
227
+ * admin", never a guess — this is an approver check on untrusted wire JSON.
228
+ */
229
+ export function isGuildAdministrator(permissions: unknown): boolean {
230
+ if (typeof permissions !== "string" || !/^\d+$/.test(permissions)) return false;
231
+ try {
232
+ return (BigInt(permissions) & ADMINISTRATOR_BIT) === ADMINISTRATOR_BIT;
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Is a pending action's FROZEN scopeKey inside this guild (`discord/{guild}` or
240
+ * `discord/{guild}/…`)? Segment-exact — "discord/G11/…" is NOT within guild "G1". This is
241
+ * the fence on the Discord-native approver path: a guild ADMINISTRATOR is authority over
242
+ * their own server's scopes, not over rows a proposer aimed at some other workspace via an
243
+ * explicit scopeKey override — those still need the config-as-code roster (clickerMayDecide).
244
+ */
245
+ export function scopeWithinGuild(scopeKey: string, guildId: string | undefined): boolean {
246
+ if (!guildId) return false;
247
+ return scopeKey === `discord/${guildId}` || scopeKey.startsWith(`discord/${guildId}/`);
248
+ }