@open-everywhere/provider-discord 0.6.0 → 0.8.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 (72) hide show
  1. package/agent/approvals/discord-admin.ts +121 -0
  2. package/agent/approvals/discord-card.ts +32 -38
  3. package/agent/approvals/discord-flow.ts +18 -6
  4. package/agent/channels/discord-channel-info.ts +43 -15
  5. package/agent/channels/discord-dispatch-target.ts +36 -1
  6. package/agent/channels/discord-events.ts +3 -2
  7. package/agent/channels/discord-friendly-names.ts +28 -0
  8. package/agent/channels/discord-front.ts +7 -3
  9. package/agent/channels/discord-gateway.ts +88 -35
  10. package/agent/channels/discord-invite.ts +46 -0
  11. package/agent/channels/discord-origin-codec.ts +11 -0
  12. package/agent/channels/discord-rest.ts +35 -2
  13. package/agent/commands/discord-commands.ts +64 -12
  14. package/agent/docs-generated.ts +506 -2
  15. package/agent/register.ts +21 -1
  16. package/agent/surface-parity.ts +3 -0
  17. package/dist/agent/approvals/discord-admin.d.ts +31 -0
  18. package/dist/agent/approvals/discord-admin.d.ts.map +1 -1
  19. package/dist/agent/approvals/discord-admin.js +107 -0
  20. package/dist/agent/approvals/discord-admin.js.map +1 -1
  21. package/dist/agent/approvals/discord-card.d.ts +13 -14
  22. package/dist/agent/approvals/discord-card.d.ts.map +1 -1
  23. package/dist/agent/approvals/discord-card.js +23 -36
  24. package/dist/agent/approvals/discord-card.js.map +1 -1
  25. package/dist/agent/approvals/discord-flow.d.ts.map +1 -1
  26. package/dist/agent/approvals/discord-flow.js +18 -7
  27. package/dist/agent/approvals/discord-flow.js.map +1 -1
  28. package/dist/agent/channels/discord-channel-info.d.ts +3 -1
  29. package/dist/agent/channels/discord-channel-info.d.ts.map +1 -1
  30. package/dist/agent/channels/discord-channel-info.js +40 -13
  31. package/dist/agent/channels/discord-channel-info.js.map +1 -1
  32. package/dist/agent/channels/discord-dispatch-target.d.ts +17 -1
  33. package/dist/agent/channels/discord-dispatch-target.d.ts.map +1 -1
  34. package/dist/agent/channels/discord-dispatch-target.js +35 -1
  35. package/dist/agent/channels/discord-dispatch-target.js.map +1 -1
  36. package/dist/agent/channels/discord-events.d.ts +3 -2
  37. package/dist/agent/channels/discord-events.d.ts.map +1 -1
  38. package/dist/agent/channels/discord-events.js.map +1 -1
  39. package/dist/agent/channels/discord-friendly-names.d.ts +7 -0
  40. package/dist/agent/channels/discord-friendly-names.d.ts.map +1 -0
  41. package/dist/agent/channels/discord-friendly-names.js +27 -0
  42. package/dist/agent/channels/discord-friendly-names.js.map +1 -0
  43. package/dist/agent/channels/discord-front.d.ts.map +1 -1
  44. package/dist/agent/channels/discord-front.js +7 -3
  45. package/dist/agent/channels/discord-front.js.map +1 -1
  46. package/dist/agent/channels/discord-gateway.d.ts +6 -0
  47. package/dist/agent/channels/discord-gateway.d.ts.map +1 -1
  48. package/dist/agent/channels/discord-gateway.js +61 -8
  49. package/dist/agent/channels/discord-gateway.js.map +1 -1
  50. package/dist/agent/channels/discord-invite.d.ts +10 -0
  51. package/dist/agent/channels/discord-invite.d.ts.map +1 -0
  52. package/dist/agent/channels/discord-invite.js +42 -0
  53. package/dist/agent/channels/discord-invite.js.map +1 -0
  54. package/dist/agent/channels/discord-origin-codec.d.ts.map +1 -1
  55. package/dist/agent/channels/discord-origin-codec.js +12 -0
  56. package/dist/agent/channels/discord-origin-codec.js.map +1 -1
  57. package/dist/agent/channels/discord-rest.d.ts.map +1 -1
  58. package/dist/agent/channels/discord-rest.js +39 -2
  59. package/dist/agent/channels/discord-rest.js.map +1 -1
  60. package/dist/agent/commands/discord-commands.d.ts.map +1 -1
  61. package/dist/agent/commands/discord-commands.js +62 -13
  62. package/dist/agent/commands/discord-commands.js.map +1 -1
  63. package/dist/agent/docs-generated.d.ts.map +1 -1
  64. package/dist/agent/docs-generated.js +506 -2
  65. package/dist/agent/docs-generated.js.map +1 -1
  66. package/dist/agent/register.d.ts.map +1 -1
  67. package/dist/agent/register.js +19 -2
  68. package/dist/agent/register.js.map +1 -1
  69. package/dist/agent/surface-parity.d.ts.map +1 -1
  70. package/dist/agent/surface-parity.js +3 -0
  71. package/dist/agent/surface-parity.js.map +1 -1
  72. package/package.json +10 -2
@@ -225,3 +225,124 @@ export async function discordAuthorRoles(guildId: string, userId: string, deps:
225
225
  export function _resetDiscordAuthorRolesCache(): void {
226
226
  roleEvidenceCache.clear();
227
227
  }
228
+
229
+ // --- CAN THIS PERSON READ THIS CHANNEL? (OPE-2534, out-of-office cover) --------------------------------------------
230
+ //
231
+ // The cover recap shows a question only where the covered person could already read it. A relay message carries no
232
+ // permission data, so the answer is Discord's own permission rule over three REST reads: the channel (its overwrites,
233
+ // and its parent for a thread), the guild (its owner and role table) and the member (their roles). FAIL-CLOSED like
234
+ // every check in this file: any failure is "cannot read", so the recap counts the question and shows nothing of it.
235
+
236
+ /** Discord's `VIEW_CHANNEL` and `ADMINISTRATOR` permission bits. */
237
+ const VIEW_CHANNEL = 1n << 10n;
238
+ const ADMINISTRATOR = 1n << 3n;
239
+ /** A public thread (10 announcement, 11 public) reads as its parent channel. A private thread (12) does not. */
240
+ const PUBLIC_THREAD_TYPES: ReadonlySet<number> = new Set([10, 11]);
241
+ const PRIVATE_THREAD_TYPE = 12;
242
+
243
+ /** One permission overwrite on a channel. `type` 0 is a role (the @everyone role's id is the guild id), 1 a member. */
244
+ export interface DiscordOverwrite {
245
+ readonly id: string;
246
+ readonly type: number;
247
+ readonly allow: string;
248
+ readonly deny: string;
249
+ }
250
+
251
+ /** The facts Discord's permission rule reads. */
252
+ export interface DiscordViewFacts {
253
+ readonly guildId: string;
254
+ readonly ownerId?: string;
255
+ /** Role id → permission bits, as the guild's role table spells them (a decimal string). */
256
+ readonly rolePermissions: ReadonlyMap<string, string>;
257
+ readonly userId: string;
258
+ readonly memberRoleIds: readonly string[];
259
+ readonly overwrites: readonly DiscordOverwrite[];
260
+ }
261
+
262
+ function permissionBits(v: string | undefined): bigint {
263
+ return v !== undefined && /^\d+$/.test(v) ? BigInt(v) : 0n;
264
+ }
265
+
266
+ /**
267
+ * Discord's documented permission rule for VIEW_CHANNEL (developer docs, "Permissions" → "Permission Overwrites"),
268
+ * in its order. The owner and ADMINISTRATOR see everything. Else the base is the @everyone role's bits OR the
269
+ * member's roles' bits. Then the @everyone overwrite, then the member's role overwrites together, then the member's
270
+ * own overwrite, each as "deny, then allow". Pure.
271
+ */
272
+ export function discordCanView(f: DiscordViewFacts): boolean {
273
+ if (f.ownerId !== undefined && f.ownerId === f.userId) return true;
274
+ let base = permissionBits(f.rolePermissions.get(f.guildId));
275
+ for (const r of f.memberRoleIds) base |= permissionBits(f.rolePermissions.get(r));
276
+ if ((base & ADMINISTRATOR) !== 0n) return true;
277
+ const everyone = f.overwrites.find((o) => o.type === 0 && o.id === f.guildId);
278
+ if (everyone) base = (base & ~permissionBits(everyone.deny)) | permissionBits(everyone.allow);
279
+ let allow = 0n;
280
+ let deny = 0n;
281
+ for (const o of f.overwrites) {
282
+ if (o.type === 0 && o.id !== f.guildId && f.memberRoleIds.includes(o.id)) {
283
+ allow |= permissionBits(o.allow);
284
+ deny |= permissionBits(o.deny);
285
+ }
286
+ }
287
+ base = (base & ~deny) | allow;
288
+ const own = f.overwrites.find((o) => o.type === 1 && o.id === f.userId);
289
+ if (own) base = (base & ~permissionBits(own.deny)) | permissionBits(own.allow);
290
+ return (base & VIEW_CHANNEL) !== 0n;
291
+ }
292
+
293
+ function overwritesOf(channel: Record<string, unknown>): DiscordOverwrite[] {
294
+ const raw = Array.isArray(channel["permission_overwrites"]) ? (channel["permission_overwrites"] as unknown[]) : [];
295
+ return raw.flatMap((o) =>
296
+ isObject(o) && typeof o["id"] === "string" && typeof o["type"] === "number"
297
+ ? [{ id: o["id"], type: o["type"], allow: String(o["allow"] ?? "0"), deny: String(o["deny"] ?? "0") }]
298
+ : [],
299
+ );
300
+ }
301
+
302
+ /**
303
+ * Can `userId` read `channelId` in `guildId`, per Discord's own rule ({@link discordCanView})? A public thread reads
304
+ * as its parent channel. Each of these answers FALSE: a private thread, a channel in another guild, a person who is
305
+ * not a member (404), a bot account (cover is for people), and every failure. NEVER throws. Not cached: cover asks
306
+ * only about a person with live cover.
307
+ */
308
+ export async function discordMemberCanViewChannel(guildId: string, channelId: string, userId: string, deps: DiscordAdminDeps = {}): Promise<boolean> {
309
+ const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
310
+ if (!token || !guildId || !channelId || !userId) return false;
311
+ const doFetch = deps.fetchFn ?? fetch;
312
+ const headers = { authorization: `Bot ${token}` };
313
+ const read = async (path: string): Promise<Record<string, unknown> | undefined> => {
314
+ const res = await doFetch(`https://discord.com/api/v10/${path}`, { headers });
315
+ if (!res.ok) return undefined;
316
+ const body = (await res.json()) as unknown;
317
+ return isObject(body) ? body : undefined;
318
+ };
319
+ try {
320
+ let channel = await read(`channels/${encodeURIComponent(channelId)}`);
321
+ if (!channel || channel["guild_id"] !== guildId || channel["type"] === PRIVATE_THREAD_TYPE) return false;
322
+ if (typeof channel["type"] === "number" && PUBLIC_THREAD_TYPES.has(channel["type"])) {
323
+ const parentId = channel["parent_id"];
324
+ channel = typeof parentId === "string" ? await read(`channels/${encodeURIComponent(parentId)}`) : undefined;
325
+ if (!channel || channel["guild_id"] !== guildId) return false;
326
+ }
327
+ const guild = await read(`guilds/${encodeURIComponent(guildId)}`);
328
+ if (!guild) return false;
329
+ const member = await read(`guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(userId)}`);
330
+ const user = member?.["user"];
331
+ if (!member || !isObject(user) || user["id"] !== userId || user["bot"] === true) return false;
332
+ const rolePermissions = new Map<string, string>();
333
+ for (const r of Array.isArray(guild["roles"]) ? (guild["roles"] as unknown[]) : []) {
334
+ if (isObject(r) && typeof r["id"] === "string") rolePermissions.set(r["id"], String(r["permissions"] ?? "0"));
335
+ }
336
+ return discordCanView({
337
+ guildId,
338
+ ...(typeof guild["owner_id"] === "string" ? { ownerId: guild["owner_id"] } : {}),
339
+ rolePermissions,
340
+ userId,
341
+ memberRoleIds: Array.isArray(member["roles"]) ? (member["roles"] as unknown[]).filter((r): r is string => typeof r === "string") : [],
342
+ overwrites: overwritesOf(channel),
343
+ });
344
+ } catch (err) {
345
+ log(`view check error for guild ${guildId}: ${(err as Error)?.message ?? String(err)}`);
346
+ return false;
347
+ }
348
+ }
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // Discord's card idiom differs from Slack's in three load-bearing ways, all handled here:
8
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.)
9
+ // ACTION_ROW. (Discord caps `content` at 2000 chars — a card is a few short lines
10
+ // of short code-rendered text, far under it.)
11
11
  // - No per-button `value` slot: Slack carries the org in the button's `value`; Discord's
12
12
  // ONLY payload carrier is the `custom_id`, so the org rides there too (same frozen-org
13
13
  // doctrine, different envelope). custom_id is capped at 100 chars — enforced below.
@@ -19,7 +19,16 @@
19
19
  // that already ran, carrying the one "This should not have run" button and never a decision one.
20
20
 
21
21
  import type { CardOptionTone, PendingActionRow } from "@open-everywhere/core/agent/adapter-api.js";
22
- import { approvalLabel, optionsWithoutConfirm, pendingCardLayout, plainSummaryForRow } from "@open-everywhere/core/agent/adapter-api.js";
22
+ import {
23
+ cardFooter,
24
+ decidedCardLine,
25
+ footerRequester,
26
+ JUDGE_LIFT_BUTTON,
27
+ judgeLiftLines,
28
+ optionsWithoutConfirm,
29
+ pendingCardLayout,
30
+ pendingCardLines,
31
+ } from "@open-everywhere/core/agent/adapter-api.js";
23
32
 
24
33
  /**
25
34
  * Every approval button custom_id this feature owns starts with this. Deliberately distinct
@@ -111,20 +120,17 @@ export function parseDiscordApprovalCustomId(customId: string): { id: string; de
111
120
  return { id, decision: decision as DiscordApprovalDecision, org };
112
121
  }
113
122
 
114
- /** The shared card body: the plain sentence first (OPE-2434), then the `Approval requested: <kind>` label, the summary and the provenance. `<@id>` is Discord's user-mention syntax too, so the requester line reads identically to the Slack card. The sentence went through core's `plainValue`, so it carries no markdown of its own. */
115
- function cardBody(row: PendingActionRow, summaryLines: readonly string[], footer: string): string {
116
- return [`**${plainSummaryForRow(row)}**`, `-# ${approvalLabel(row.kind)}`, ...summaryLines, footer].join("\n");
117
- }
118
-
119
123
  const OPTION_STYLE: Record<CardOptionTone, number> = { primary: BUTTON_STYLE_SUCCESS, danger: BUTTON_STYLE_DANGER, neutral: BUTTON_STYLE_SECONDARY };
120
124
 
121
125
  /**
122
- * The card for a still-pending action: the plain sentence, the summary, "requested by / expires", and one button per layout option, each carrying the pending id + org in its custom_id (see the file header for why the org rides the custom_id on Discord). Mirrors card.ts's pendingCardBlocks.
126
+ * The card for a still-pending action (OPE-2481): the headline in bold, at most one consequence line, a warning when the proposal was tainted, the small footer, and one button per layout option, each carrying the pending id + org in its custom_id (see the file header for why the org rides the custom_id on Discord). The kind never appears: Discord has no hidden metadata slot, so the kind lives on the row the custom_id names. Mirrors card.ts's pendingCardBlocks.
123
127
  * The OPTIONS come from core's card layout (OPE-53, OPE-2363: only a ROUTINE's card gets the middle grant button), so Slack, Teams and Discord can never disagree. OPE-2456's rule option needs a confirmation before the click, which a Discord button cannot show, so it is left out.
128
+ * The headline and the consequence went through core's `plainValue`, so they carry no markdown of their own. `<@id>` is Discord's user-mention syntax too, so the footer reads as the Slack card's does.
124
129
  */
125
130
  export function pendingDiscordCard(row: PendingActionRow, summaryLines: readonly string[]): DiscordCardMessage {
126
131
  const layout = pendingCardLayout(row, summaryLines);
127
- const content = cardBody(row, summaryLines, `-# Requested by <@${layout.requestedBy}> · ${layout.expiry}`);
132
+ const [headline, ...rest] = pendingCardLines(layout);
133
+ const content = [`**${headline}**`, ...rest, `-# ${cardFooter(footerRequester(row, "discord"), layout.expiry)}`].join("\n");
128
134
  // A Discord button sends its click with no confirmation first, so the rule option (OPE-2456) is left out here.
129
135
  const buttons: DiscordComponent[] = optionsWithoutConfirm(layout.options).map((o) => ({
130
136
  type: COMPONENT_BUTTON,
@@ -146,18 +152,18 @@ export function pendingDiscordCard(row: PendingActionRow, summaryLines: readonly
146
152
  * `approval.judge.contradicted` row the contradiction breaker counts.
147
153
  *
148
154
  * NOT AN APPROVAL CARD, and the render says so: no Approve, no Reject, no expiry line. The act is
149
- * already done, so this is a review surface, not a decision surface. It deliberately does NOT reuse
150
- * `cardBody` — that helper's header reads "Approval requested", which is the one thing this message
151
- * must never say.
155
+ * already done, so this is a review surface, not a decision surface.
152
156
  *
153
- * THE COPY IS THE SLACK COPY, word for word (the header, the ⚡ line, the button label). Two rooms
154
- * on two platforms must not be told different things about the same mechanism; only the envelope
155
- * differs — Markdown `content` + a components row, rather than blocks.
157
+ * THE COPY IS THE SLACK COPY, word for word (core's `judgeLiftHeadline`, `judgeLiftOutcome` and
158
+ * `JUDGE_LIFT_BUTTON`). Two rooms on two platforms must not be told different things about the same
159
+ * mechanism; only the envelope differs — Markdown `content` + a components row, rather than blocks.
160
+ * Two lines and the button (OPE-2481): the headline names the act in plain words, never its kind.
156
161
  */
157
- export function judgeLiftNoticeDiscordCard(row: PendingActionRow, summaryLines: readonly string[], outcomeLine: string): DiscordCardMessage {
158
- const context = `-# ⚡ Auto-approved by the approval judge — no card was posted and this has already run · ${outcomeLine}`;
162
+ export function judgeLiftNoticeDiscordCard(row: PendingActionRow, _summaryLines: readonly string[], outcomeLine: string): DiscordCardMessage {
159
163
  return {
160
- content: [`**Ran without asking: ${row.kind}**`, ...summaryLines, context].join("\n"),
164
+ content: judgeLiftLines(row, outcomeLine)
165
+ .map((line, i, all) => (i === 0 ? `**${line}**` : i === all.length - 1 ? `-# ${line}` : line))
166
+ .join("\n"),
161
167
  components: [
162
168
  {
163
169
  type: COMPONENT_ACTION_ROW,
@@ -165,7 +171,7 @@ export function judgeLiftNoticeDiscordCard(row: PendingActionRow, summaryLines:
165
171
  {
166
172
  type: COMPONENT_BUTTON,
167
173
  style: BUTTON_STYLE_DANGER,
168
- label: "This should not have run",
174
+ label: JUDGE_LIFT_BUTTON,
169
175
  custom_id: buildDiscordApprovalCustomId(row, "contradict"),
170
176
  },
171
177
  ],
@@ -175,25 +181,13 @@ export function judgeLiftNoticeDiscordCard(row: PendingActionRow, summaryLines:
175
181
  }
176
182
 
177
183
  /**
178
- * The card once a decision has landed: SAME summary, buttons GONE (`components: []` — the
179
- * type-7 UPDATE_MESSAGE response replaces the whole message, so an empty row strips them),
180
- * plus a context line naming who decided + the outcome. Renders whatever the caller already
181
- * CAS-decided — this function never decides anything (mirrors card.ts's decidedCardBlocks,
182
- * including the same three status lines).
184
+ * The card once a decision has landed: ONE line (OPE-2481), buttons GONE (`components: []` — the
185
+ * type-7 UPDATE_MESSAGE response replaces the whole message, so an empty row strips them). The line is
186
+ * core's `decidedCardLine`, so it reads exactly as the Slack card's does, including a durable
187
+ * decline's line. Renders whatever the caller already CAS-decided — this function never decides anything.
183
188
  */
184
- export function decidedDiscordCard(row: PendingActionRow, summaryLines: readonly string[], outcomeLine: string): DiscordCardMessage {
185
- const decidedContext =
186
- row.status === "approved"
187
- ? `✅ Approved by <@${row.decidedBy}> · ${outcomeLine}`
188
- : row.status === "rejected"
189
- ? // A kind whose DECLINE is itself durable returns a line saying what the decline recorded
190
- // (approvals/types.ts ActionKind.declined). Every other kind's reject passes the literal
191
- // "rejected" and renders byte-for-byte what it always did. Kept in step with card.ts's
192
- // decidedCardBlocks by hand, exactly like the three status lines above it — a Discord card
193
- // that dropped the line would leave a clicker believing a decline was remembered.
194
- `❌ Rejected by <@${row.decidedBy}>${outcomeLine === "rejected" ? "" : ` · ${outcomeLine}`}`
195
- : "⏰ Expired";
196
- return { content: cardBody(row, summaryLines, decidedContext), components: [] };
189
+ export function decidedDiscordCard(row: PendingActionRow, _summaryLines: readonly string[], outcomeLine: string): DiscordCardMessage {
190
+ return { content: decidedCardLine(row, outcomeLine, `<@${row.decidedBy}>`), components: [] };
197
191
  }
198
192
 
199
193
  /** Discord's ADMINISTRATOR permission bit (0x8). https://discord.com/developers/docs/topics/permissions */
@@ -66,6 +66,7 @@ import {
66
66
  type DecisionWakeDeps,
67
67
  type ApprovalOutcome,
68
68
  type PendingActionRow,
69
+ CLICK_NOTICE,
69
70
  } from "@open-everywhere/core/agent/adapter-api.js";
70
71
  import {
71
72
  OE_DISCORD_APPROVAL_PREFIX,
@@ -277,7 +278,7 @@ export interface DiscordApprovalDeps {
277
278
  export function discordResponseFor(res: ApprovalClickResolution): DiscordInteractionResponse {
278
279
  switch (res.outcome) {
279
280
  case "unknown":
280
- return ephemeral("This approval request is no longer known (it may have already been decided or has aged out).");
281
+ return ephemeral(CLICK_NOTICE.unknown);
281
282
  case "not-pending":
282
283
  case "expired-race":
283
284
  case "unregistered-kind":
@@ -288,7 +289,7 @@ export function discordResponseFor(res: ApprovalClickResolution): DiscordInterac
288
289
  return { type: RESPONSE_UPDATE_MESSAGE, data: { content: card.content, components: card.components } };
289
290
  }
290
291
  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
+ return ephemeral(CLICK_NOTICE.deniedDiscord);
292
293
  case "clicker-denied":
293
294
  // The clicker gate (OPE-185, core approvals/clicker-floor.ts) — same UX as `denied`: an
294
295
  // ephemeral to the clicker alone, card untouched, row still pending.
@@ -357,7 +358,7 @@ export async function handleDiscordApprovalInteraction(raw: unknown, deps: Disco
357
358
  if (!click.clickerId) {
358
359
  // It WAS ours; fail soft on the click rather than forward a half-understood payload.
359
360
  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
+ return ephemeral(CLICK_NOTICE.noClicker);
361
362
  }
362
363
  const clickerId = click.clickerId;
363
364
 
@@ -395,8 +396,11 @@ export async function handleDiscordApprovalInteraction(raw: unknown, deps: Disco
395
396
  return rowGuild ? isGuildAdminViaRest(rowGuild, clickerId) : false;
396
397
  },
397
398
  });
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.
399
+ // Decision FEEDBACK (task #106 — Slack click-path parity): the cross-thread window wake, and a
400
+ // fresh notice beside the card that states the outcome only when no seat took the wake or a thread
401
+ // seat or a worker owns the card's channel (the UPDATE_MESSAGE response is an EDIT, invisible to
402
+ // backscroll; the room never reads the decided line twice, OPE-2473). Otherwise only an asker whom
403
+ // somebody else answered, or whose request expired, gets one short line (OPE-2490).
400
404
  // The wake's guild id comes from the SIGNED interaction (Slack's team.id analogue — the frozen
401
405
  // scopeKey can name a different workspace under an explicit-scope override). A DM-context
402
406
  // interaction has none, so the core takes the card's mint-frozen DM `postedTo` as the DM seat's
@@ -419,6 +423,10 @@ export async function handleDiscordApprovalInteraction(raw: unknown, deps: Disco
419
423
  outcomeLine: "expired",
420
424
  executed: "none",
421
425
  target: discordDecisionWakeTarget(res.row, click.guildId),
426
+ // The interaction answer (UPDATE_MESSAGE) edits the card to its decided line.
427
+ cardShowsOutcome: true,
428
+ // The notice posts into the card's own channel; a Discord thread IS its channel id.
429
+ noticeThread: discordDecisionWakeTarget(res.row, click.guildId)?.origin ?? null,
422
430
  postNotice: (text) => (deps.postNotice ?? postDiscordDecisionNotice)(res.row, text),
423
431
  },
424
432
  deps.decisionWake,
@@ -434,6 +442,10 @@ export async function handleDiscordApprovalInteraction(raw: unknown, deps: Disco
434
442
  executed: res.executed,
435
443
  clickerId,
436
444
  target: discordDecisionWakeTarget(res.row, click.guildId),
445
+ // The interaction answer (UPDATE_MESSAGE) edits the card to its decided line.
446
+ cardShowsOutcome: true,
447
+ // The notice posts into the card's own channel; a Discord thread IS its channel id.
448
+ noticeThread: discordDecisionWakeTarget(res.row, click.guildId)?.origin ?? null,
437
449
  postNotice: (text) => (deps.postNotice ?? postDiscordDecisionNotice)(res.row, text),
438
450
  },
439
451
  deps.decisionWake,
@@ -465,6 +477,6 @@ export async function handleDiscordApprovalInteraction(raw: unknown, deps: Disco
465
477
  // Genuinely unexpected (store outage, …) after we'd established this WAS our click —
466
478
  // fail soft with an honest ephemeral note; never throw out of the interactions path.
467
479
  console.error(`[open-everywhere:discord-approvals] click handling failed softly for "${click.customId}": ${String(err)}`);
468
- return ephemeral("Something went wrong handling this click — the decision was not applied.");
480
+ return ephemeral(CLICK_NOTICE.failed);
469
481
  }
470
482
  }
@@ -1,16 +1,18 @@
1
- // Shared Discord channel-info lookup — `GET /channels/{id}` (and `GET /guilds/{id}`)
2
- // via the bot token, cached per id. ONE impl + ONE cache for the two consumers that
1
+ // Shared Discord channel-info lookup — `GET /channels/{id}` (and `GET /guilds/{id}`, `GET /users/{id}`)
2
+ // via the bot token, cached per id. ONE impl + ONE cache for the three consumers that
3
3
  // need the same facts:
4
4
  // - approvals/scope-label.ts — friendly card labels ("#parent · thread \"…\"");
5
5
  // - channels/discord-gateway.ts — thread→parent grain resolution (backlog item 22):
6
6
  // a Discord thread is its OWN channel id, so domain/spend grain must resolve the
7
- // PARENT channel or a thread silently falls out of its channel's domain and cap.
7
+ // PARENT channel or a thread silently falls out of its channel's domain and cap;
8
+ // - channels/discord-friendly-names.ts — the friendly-names guard's resolver (OPE-2482): a raw
9
+ // snowflake in text a person reads renders as a channel, server or person name.
8
10
  //
9
11
  // FAIL-OPEN BY CONTRACT: every failure (no token, non-ok status, network error,
10
12
  // malformed body) resolves `undefined` — the caller degrades (raw key on a card,
11
13
  // thread-grain accounting on the gateway), never blocks or throws. The bot token is a
12
- // SECRET — header only, never logged. Verdicts (including some failures — see the
13
- // cache note) are cached in-process; a serverless instance pays each lookup once.
14
+ // SECRET — header only, never logged. Definitive verdicts are cached in-process (see the
15
+ // cache note); a transient failure is asked again.
14
16
 
15
17
  /** The channel facts both consumers read. `parentId` is set ONLY for threads. */
16
18
  export interface DiscordChannelInfo {
@@ -22,6 +24,8 @@ export interface DiscordChannelInfo {
22
24
  * Read by the declared-target resolver (OPE-2419) to confirm a configured `discord/<guild>/<channel>`
23
25
  * names a channel that really is in that guild. */
24
26
  readonly guildId?: string;
27
+ /** A USER lookup's `username`: the handle Discord keeps unique (OPE-2482). Absent for a channel or guild. */
28
+ readonly handle?: string;
25
29
  }
26
30
 
27
31
  /** Injectable seams (tests swap both; live callers pass nothing). */
@@ -30,12 +34,14 @@ export interface DiscordInfoDeps {
30
34
  readonly token?: string;
31
35
  }
32
36
 
33
- // Promise-cached per `kind:id` — concurrent callers share one in-flight request, and a
34
- // settled result (including `undefined` from a hard failure) is kept for the instance's
35
- // life: names/parents change on admin edits, not per message, and the pre-extraction
36
- // scope-label cache behaved exactly this way (a card propose + its decided re-render
37
- // must not pay twice).
37
+ // Promise-cached per `kind:id` — concurrent callers share one in-flight request. A DEFINITIVE
38
+ // settled result (a name, a 404, no token) is kept, up to INFO_CACHE_MAX ids, because
39
+ // names/parents change on admin edits, not per message (a card propose + its decided
40
+ // re-render must not pay twice). A TRANSIENT failure (429, 5xx, a network error) is dropped
41
+ // once it settles, so the next caller asks again.
38
42
  const infoCache = new Map<string, Promise<DiscordChannelInfo | undefined>>();
43
+ /** The most verdicts the cache holds; the oldest goes first. */
44
+ const INFO_CACHE_MAX = 2000;
39
45
 
40
46
  /** Test hook: drop every cached verdict (unit suites re-run lookups with fresh fakes). */
41
47
  export function clearDiscordInfoCacheForTest(): void {
@@ -43,13 +49,18 @@ export function clearDiscordInfoCacheForTest(): void {
43
49
  }
44
50
 
45
51
  export function fetchDiscordInfo(
46
- kind: "channels" | "guilds",
52
+ kind: "channels" | "guilds" | "users",
47
53
  id: string,
48
54
  deps: DiscordInfoDeps = {},
49
55
  ): Promise<DiscordChannelInfo | undefined> {
50
56
  const key = `${kind}:${id}`;
51
57
  let p = infoCache.get(key);
52
58
  if (!p) {
59
+ // A TRANSIENT failure (a 429, a 5xx, a network error) is NOT kept (OPE-2482): the friendly-names
60
+ // resolver now looks ids up on the post path, and a cached rate-limit miss would otherwise drop a
61
+ // thread out of its parent's grain for the instance's life. A definitive answer — a found name, a
62
+ // 404, no token — is kept.
63
+ let transient = false;
53
64
  p = (async () => {
54
65
  const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
55
66
  if (!token) return undefined;
@@ -58,21 +69,38 @@ export function fetchDiscordInfo(
58
69
  const res = await doFetch(`https://discord.com/api/v10/${kind}/${id}`, {
59
70
  headers: { authorization: `Bot ${token}` },
60
71
  });
61
- if (!res.ok) return undefined;
62
- const body = (await res.json()) as { name?: unknown; parent_id?: unknown; type?: unknown; guild_id?: unknown };
63
- const name = typeof body.name === "string" && body.name.trim() ? body.name.trim() : undefined;
72
+ if (!res.ok) {
73
+ transient = res.status === 429 || res.status >= 500;
74
+ return undefined;
75
+ }
76
+ const body = (await res.json()) as { name?: unknown; parent_id?: unknown; type?: unknown; guild_id?: unknown; global_name?: unknown; username?: unknown };
77
+ // A user carries no `name`: its display name is `global_name`, else its `username` (OPE-2482).
78
+ const pick = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v.trim() : undefined);
79
+ const name = kind === "users" ? (pick(body.global_name) ?? pick(body.username)) : pick(body.name);
64
80
  const isThread = body.type === 10 || body.type === 11 || body.type === 12;
65
81
  const parentId = isThread && typeof body.parent_id === "string" && body.parent_id ? body.parent_id : undefined;
66
82
  // A nameless channel resolves undefined wholesale — kept from the original
67
83
  // scope-label impl (its callers require the name), and harmless for the grain
68
84
  // consumer: every real guild channel/thread carries a name.
69
85
  const guildId = kind === "channels" && typeof body.guild_id === "string" && body.guild_id ? body.guild_id : undefined;
70
- return name ? { name, parentId, isThread, ...(guildId ? { guildId } : {}) } : undefined;
86
+ const handle = kind === "users" ? pick(body.username) : undefined;
87
+ return name ? { name, parentId, isThread, ...(guildId ? { guildId } : {}), ...(handle ? { handle } : {}) } : undefined;
71
88
  } catch {
89
+ transient = true;
72
90
  return undefined;
73
91
  }
74
92
  })();
93
+ const settled = p;
94
+ void settled.then(() => {
95
+ if (transient && infoCache.get(key) === settled) infoCache.delete(key);
96
+ });
75
97
  infoCache.set(key, p);
98
+ // Bounded: the oldest verdict goes first once the cache holds INFO_CACHE_MAX ids.
99
+ while (infoCache.size > INFO_CACHE_MAX) {
100
+ const oldest = infoCache.keys().next().value;
101
+ if (oldest === undefined) break;
102
+ infoCache.delete(oldest);
103
+ }
76
104
  }
77
105
  return p;
78
106
  }
@@ -12,13 +12,14 @@
12
12
  import type {
13
13
  Routine,
14
14
  DispatchTarget,
15
+ RoutineTestRunArm,
15
16
  DeclaredTargetResolution,
16
17
  DeclaredTargetSpec,
17
18
  SessionMode,
18
19
  UnifiedWakeOrigin,
19
20
  } from "@open-everywhere/core/agent/adapter-api.js";
20
21
  import discord from "./discord.js";
21
- import { discordOpenDmChannel } from "./discord-post.js";
22
+ import { discordOpenDmChannel, discordPostMessage } from "./discord-post.js";
22
23
  import { fetchDiscordInfo } from "./discord-channel-info.js";
23
24
 
24
25
  /** One stored-target field as a non-empty string, or undefined (never a coerced non-string — the
@@ -162,3 +163,37 @@ export async function resolveDiscordDeclaredTarget(
162
163
  }
163
164
  return { ok: true, facts: { surfaceKey: `discord/${spec.principalId}/${channelId}`, target: { channelId, channelType: "1" } } };
164
165
  }
166
+
167
+ /** The channel a resolved Discord target posts into, or undefined. */
168
+ function targetChannelId(dest: DispatchTarget): string | undefined {
169
+ const v = (dest.target as { channelId?: unknown }).channelId;
170
+ return typeof v === "string" && v.length > 0 ? v : undefined;
171
+ }
172
+
173
+ /**
174
+ * THE DISCORD TEST-RUN ARM (OPE-2540, core routines/test-run.ts). A test needs a session of its own,
175
+ * and Discord has no thread ts to key one on. eve's Discord channel keys a session on
176
+ * `(channelId, conversationId)` (`discordContinuationToken`), and a proactive fire with no
177
+ * `conversationId` keys on the channel alone, which is the room's own session. So the arm posts the
178
+ * test's opening message and fires with that message's id as the `conversationId`: a session of its
179
+ * own, in the routine's own channel.
180
+ *
181
+ * The opening post, the run's answer and the summary all land in the routine's channel. The summary is
182
+ * a Discord REPLY to the opening post (`message_reference`), so it shows which test it closes.
183
+ * The fire's bag is unchanged, so the test binds to the routine's own channel and guild.
184
+ *
185
+ * Not a Discord thread: a thread is its own channel id, and bindDiscord would then bind the test to a
186
+ * different surface than the routine's.
187
+ */
188
+ export const discordTestRunArm: RoutineTestRunArm = {
189
+ post: async (dest, text, anchor) => {
190
+ const channelId = targetChannelId(dest);
191
+ if (channelId === undefined) return undefined;
192
+ try {
193
+ return await discordPostMessage(channelId, text, anchor !== undefined ? { replyToMessageId: anchor } : undefined);
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ },
198
+ target: (dest, anchor) => ({ channelId: targetChannelId(dest) ?? "", conversationId: anchor }),
199
+ };
@@ -30,6 +30,7 @@
30
30
  import { POST, type Channel, type RouteHandlerArgs } from "eve/channels";
31
31
  import { verifyDiscordInteraction } from "./discord-front.js";
32
32
  import { appendAudit, deploymentOrg } from "@open-everywhere/core/agent/adapter-api.js";
33
+ import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
33
34
 
34
35
  /** The public URL to paste into the app's developer-portal **Webhooks** page (NOT the
35
36
  * Interactions Endpoint URL, which stays {@link PUBLIC_DISCORD_ROUTE}). */
@@ -48,7 +49,7 @@ function elog(msg: string): void {
48
49
  * all of which still get Discord's required 204. */
49
50
  export type DiscordEventDecision =
50
51
  | { readonly kind: "ack"; readonly reason: string }
51
- | { readonly kind: "audit"; readonly action: string; readonly workspace: string; readonly principalId?: string; readonly detail: Record<string, unknown> };
52
+ | { readonly kind: "audit"; readonly action: string; readonly workspace: string; readonly principalId?: string; readonly detail: AuditDetail };
52
53
 
53
54
  function str(v: unknown): string | undefined {
54
55
  return typeof v === "string" && v.length > 0 ? v : undefined;
@@ -118,7 +119,7 @@ export interface DiscordEventsOptions {
118
119
  readonly publicKey?: string;
119
120
  readonly now?: () => number;
120
121
  /** Audit writer (defaults to the real appendAudit under the deployment org). */
121
- readonly audit?: (row: { action: string; workspace: string; principalId?: string; detail: Record<string, unknown> }) => Promise<void>;
122
+ readonly audit?: (row: { action: string; workspace: string; principalId?: string; detail: AuditDetail }) => Promise<void>;
122
123
  }
123
124
 
124
125
  /** Discord requires an EMPTY 204 body for both the PING and every event, within 3 seconds. */
@@ -0,0 +1,28 @@
1
+ // discord-friendly-names — the Discord name resolver for the friendly-names guard (OPE-2482).
2
+ //
3
+ // A Discord id is a bare snowflake: the same 17-to-20-digit shape names a channel, a server and a
4
+ // person, so the text alone cannot say which. This resolver asks in that order — `GET /channels/{id}`,
5
+ // then `GET /guilds/{id}`, then `GET /users/{id}` — through the one cached lookup
6
+ // (`discord-channel-info.ts`), and the first that answers names the id. A channel answers `#name`, which
7
+ // the guard uses only off Discord: on Discord a channel renders as the native `<#id>` mention. Every
8
+ // failure answers undefined, and the id then renders as plain words, never as itself.
9
+
10
+ import type { FriendlyNameResolver } from "@open-everywhere/core/agent/adapter-api.js";
11
+ import { fetchDiscordInfo, type DiscordChannelInfo } from "./discord-channel-info.js";
12
+
13
+ /** The lookup the resolver uses. Live code passes none; a test injects it. */
14
+ export type DiscordInfoLookup = (kind: "channels" | "guilds" | "users", id: string) => Promise<DiscordChannelInfo | undefined>;
15
+
16
+ /** Build the Discord resolver. */
17
+ export function discordFriendlyNameResolver(lookup: DiscordInfoLookup = (kind, id) => fetchDiscordInfo(kind, id)): FriendlyNameResolver {
18
+ return async (id) => {
19
+ if (!/^\d{17,20}$/.test(id)) return undefined;
20
+ const channel = await lookup("channels", id);
21
+ if (channel?.name) return { label: `#${channel.name}`, kind: "channel" };
22
+ const guild = await lookup("guilds", id);
23
+ if (guild?.name) return { label: guild.name, kind: "server" };
24
+ const user = await lookup("users", id);
25
+ if (user?.name) return { label: user.name, kind: "person" };
26
+ return undefined;
27
+ };
28
+ }
@@ -33,7 +33,7 @@ import { POST, type Channel, type HttpRouteDefinition, type RouteHandlerArgs } f
33
33
  import { verifyDiscordSignature } from "eve/channels/discord";
34
34
  import { OE_DISCORD_APPROVAL_PREFIX } from "../approvals/discord-card.js";
35
35
  import { handleDiscordApprovalInteraction, type DiscordInteractionResponse } from "../approvals/discord-flow.js";
36
- import { LINK_CONSENT_CLICK_PREFIX, OE_GRANT_ACTION_PREFIX } from "@open-everywhere/core/agent/adapter-api.js";
36
+ import { LINK_CONSENT_CLICK_PREFIX, OE_GRANT_ACTION_PREFIX, friendlyDiscordBody } from "@open-everywhere/core/agent/adapter-api.js";
37
37
  import { handleDiscordLinkConsentInteraction } from "../identity/discord-link-consent.js";
38
38
  import { handleDiscordGrantCardInteraction } from "../access/discord-grant-click.js";
39
39
  import { oeCommandName, type AttachSessionForCancel } from "@open-everywhere/core/agent/adapter-api.js";
@@ -205,8 +205,12 @@ export interface DiscordFrontOptions {
205
205
  readonly handleOeCommand?: (raw: unknown, attachSession?: AttachSessionForCancel) => Promise<DiscordInteractionResponse | null>;
206
206
  }
207
207
 
208
- function interactionJson(body: DiscordInteractionResponse): Response {
209
- return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
208
+ /** An interaction answer. It renders through the friendly-names guard (OPE-2482): a click's reply and
209
+ * a card's re-render are text a person reads, and they leave this host here, not through `discordFetch`.
210
+ * It renders with NO name lookup: Discord drops an answer that misses its three-second window, and a
211
+ * click has already spent part of it, so the answer takes channel mentions and plain words. */
212
+ async function interactionJson(body: DiscordInteractionResponse): Promise<Response> {
213
+ return new Response(JSON.stringify(await friendlyDiscordBody(body, { lookup: false })), { status: 200, headers: { "content-type": "application/json" } });
210
214
  }
211
215
 
212
216
  /**