@open-everywhere/provider-discord 0.7.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 (49) hide show
  1. package/agent/approvals/discord-admin.ts +121 -0
  2. package/agent/approvals/discord-flow.ts +18 -6
  3. package/agent/channels/discord-dispatch-target.ts +36 -1
  4. package/agent/channels/discord-events.ts +3 -2
  5. package/agent/channels/discord-gateway.ts +85 -30
  6. package/agent/channels/discord-invite.ts +46 -0
  7. package/agent/channels/discord-origin-codec.ts +11 -0
  8. package/agent/commands/discord-commands.ts +53 -1
  9. package/agent/docs-generated.ts +506 -2
  10. package/agent/register.ts +8 -1
  11. package/agent/surface-parity.ts +3 -0
  12. package/dist/agent/approvals/discord-admin.d.ts +31 -0
  13. package/dist/agent/approvals/discord-admin.d.ts.map +1 -1
  14. package/dist/agent/approvals/discord-admin.js +107 -0
  15. package/dist/agent/approvals/discord-admin.js.map +1 -1
  16. package/dist/agent/approvals/discord-flow.d.ts.map +1 -1
  17. package/dist/agent/approvals/discord-flow.js +18 -7
  18. package/dist/agent/approvals/discord-flow.js.map +1 -1
  19. package/dist/agent/channels/discord-dispatch-target.d.ts +17 -1
  20. package/dist/agent/channels/discord-dispatch-target.d.ts.map +1 -1
  21. package/dist/agent/channels/discord-dispatch-target.js +35 -1
  22. package/dist/agent/channels/discord-dispatch-target.js.map +1 -1
  23. package/dist/agent/channels/discord-events.d.ts +3 -2
  24. package/dist/agent/channels/discord-events.d.ts.map +1 -1
  25. package/dist/agent/channels/discord-events.js.map +1 -1
  26. package/dist/agent/channels/discord-gateway.d.ts +6 -0
  27. package/dist/agent/channels/discord-gateway.d.ts.map +1 -1
  28. package/dist/agent/channels/discord-gateway.js +58 -6
  29. package/dist/agent/channels/discord-gateway.js.map +1 -1
  30. package/dist/agent/channels/discord-invite.d.ts +10 -0
  31. package/dist/agent/channels/discord-invite.d.ts.map +1 -0
  32. package/dist/agent/channels/discord-invite.js +42 -0
  33. package/dist/agent/channels/discord-invite.js.map +1 -0
  34. package/dist/agent/channels/discord-origin-codec.d.ts.map +1 -1
  35. package/dist/agent/channels/discord-origin-codec.js +12 -0
  36. package/dist/agent/channels/discord-origin-codec.js.map +1 -1
  37. package/dist/agent/commands/discord-commands.d.ts.map +1 -1
  38. package/dist/agent/commands/discord-commands.js +52 -2
  39. package/dist/agent/commands/discord-commands.js.map +1 -1
  40. package/dist/agent/docs-generated.d.ts.map +1 -1
  41. package/dist/agent/docs-generated.js +506 -2
  42. package/dist/agent/docs-generated.js.map +1 -1
  43. package/dist/agent/register.d.ts.map +1 -1
  44. package/dist/agent/register.js +8 -2
  45. package/dist/agent/register.js.map +1 -1
  46. package/dist/agent/surface-parity.d.ts.map +1 -1
  47. package/dist/agent/surface-parity.js +3 -0
  48. package/dist/agent/surface-parity.js.map +1 -1
  49. package/package.json +6 -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
+ }
@@ -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
  }
@@ -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. */
@@ -48,6 +48,7 @@
48
48
  // expires by itself, so there is nothing to track or clean up across
49
49
  // turns); set "false"/"0" off.
50
50
 
51
+ import { auditErrorDetail } from "@open-everywhere/core/agent/adapter-api.js";
51
52
  import { defineChannel, POST, type RouteHandlerArgs } from "eve/channels";
52
53
  import { buildDiscordAuth, type OpenEverywhereDiscordAuth } from "./discord-identity.js";
53
54
  import { threadParentChannelId } from "./discord-channel-info.js";
@@ -83,6 +84,7 @@ import {
83
84
  type AttachSessionForCancel,
84
85
  domainKeyOf,
85
86
  resolvePartition,
87
+ auditSurfaceModeFrom,
86
88
  nameDropWakeEnabled,
87
89
  seatCatchUp,
88
90
  seatCatchUpSinceMs,
@@ -134,6 +136,8 @@ import {
134
136
  healPrunedDeploymentStrand,
135
137
  engagedFollowUpContract,
136
138
  dispatchNativeWakeWithRetry,
139
+ parseVoiceSync,
140
+ syncDiscordVoice,
137
141
  } from "@open-everywhere/core/agent/adapter-api.js";
138
142
 
139
143
  // Re-exported so every existing importer (scripts/discord-gateway-test.ts, the completion
@@ -174,7 +178,9 @@ import {
174
178
  auditNoReplySentinelObserved,
175
179
  getSettings,
176
180
  authPrincipalId,
181
+ coverForWake,
177
182
  } from "@open-everywhere/core/agent/adapter-api.js";
183
+ import { discordMemberCanViewChannel } from "../approvals/discord-admin.js";
178
184
  import { fetchDiscordBackscrollLines, gradeDiscordContextLines, DISCORD_RAW_FETCH_REACHBACK_HOURS } from "./discord-backscroll.js";
179
185
 
180
186
  function log(msg: string): void {
@@ -272,6 +278,20 @@ function strOrNum(v: unknown): string | undefined {
272
278
  return undefined;
273
279
  }
274
280
 
281
+ /**
282
+ * The people a Discord message mentions (OPE-2534, out-of-office cover), by user id, in the order the message names
283
+ * them: `<@id>` and the legacy nickname form `<@!id>`. A role mention (`<@&id>`), a channel mention (`<#id>`) and the
284
+ * bot's own mention are no person, so they are left out. Pure.
285
+ */
286
+ export function discordMentionedUserIds(content: string, botId?: string): string[] {
287
+ const ids: string[] = [];
288
+ for (const m of content.matchAll(/<@!?(\d{17,20})>/g)) {
289
+ const id = m[1]!;
290
+ if (id !== botId) ids.push(id);
291
+ }
292
+ return ids;
293
+ }
294
+
275
295
  /**
276
296
  * Defensively parse the relay envelope. Returns null for anything not recognizably a relay
277
297
  * MESSAGE_CREATE with the identity facts we MUST have to dispatch + reply — a channel id (for
@@ -1952,7 +1972,7 @@ async function divertDiscordDmToWindow(input: {
1952
1972
  // dominant convention for a wake-shaped audit row (line ~2112/2259/2330 below), not the raw
1953
1973
  // `partition.surface` channel id.
1954
1974
  await auditSupervisorFailure({ org, platform: input.partition.platform, workspace: input.partition.workspace, surface: surfaceKey }, "surface.wake_failed", {
1955
- message: String(err instanceof Error ? err.message : err).slice(0, 500),
1975
+ message: auditErrorDetail(err).error,
1956
1976
  wakeId,
1957
1977
  addressing: "direct-mention",
1958
1978
  });
@@ -2144,34 +2164,55 @@ export async function divertDiscordWakeToUnifiedWindow(input: {
2144
2164
  // channel's wake stays top-level and the standing seat keeps it.
2145
2165
  ...(isThreadChannelType(msg.channelType) ? { thread: true as const } : {}),
2146
2166
  };
2147
- const rendered = renderWake(
2148
- buildDiscordUnifiedWake({
2149
- guildId: msg.guildId ?? "",
2150
- channelId: msg.channelId,
2151
- parentChannelId: str(input.auth.attributes.parent_channel_id),
2152
- isThread: isThreadChannelType(msg.channelType),
2153
- addressing: input.addressing,
2154
- threadEngaged: input.threadEngaged,
2155
- wakeId,
2156
- engagementTier: discordEngagementTier(domainKeyOf(input.partition)),
2157
- tail,
2158
- // 🔴 THE HYDRATION RIDES THE WAKE, NOT A PRE-RENDERED STRING (OPE-1749). `renderWake` holds
2159
- // both slots — these lines and `tail` — so core de-duplicates them against each other and
2160
- // spends the cap below that de-duplication, the same road every other surface takes.
2161
- contextLines: input.contextLines,
2162
- contextSource: input.contextSource,
2163
- botName: msg.botName,
2164
- catchUpUnavailable,
2165
- trigger: {
2166
- id: msg.id || "unknown",
2167
- author: msg.author.username ?? msg.author.id,
2168
- authorId: msg.author.id,
2169
- sentAt: atIso,
2170
- text: msg.content,
2171
- trust,
2172
- },
2173
- }),
2174
- );
2167
+ // 🔴 OUT-OF-OFFICE COVER (OPE-2534, the Slack seam of OPE-2513). A message that mentions a person with live cover
2168
+ // gets OE's cover note on THIS wake, and nothing else changes: the binding, the auth bag and the room's memory stay
2169
+ // the room's, so the answer can use only what this room already knows. The reply's label and the recap key on this
2170
+ // wake id (cover/wake.ts). A cover read that fails covers nobody.
2171
+ // No Discord room is public to the recap, because a relay message carries no permission data. Discord's own rule
2172
+ // (approvals/discord-admin.ts `discordMemberCanViewChannel`) confirms, per covered person, that they can read it.
2173
+ const cover = await coverForWake({
2174
+ org,
2175
+ platform: "discord",
2176
+ workspace: input.partition.workspace,
2177
+ surface: input.partition.surface,
2178
+ surfaceKey: input.partition.surfaceKey,
2179
+ channelId: msg.channelId,
2180
+ roomPublic: false,
2181
+ wakeId,
2182
+ mentionedIds: discordMentionedUserIds(msg.content, msg.botId),
2183
+ askerId: msg.author.id,
2184
+ ...(msg.author.username ? { askerName: msg.author.username } : {}),
2185
+ text: msg.content,
2186
+ now: new Date(atIso),
2187
+ ...(msg.guildId ? { confirmInRoom: (id: string) => discordMemberCanViewChannel(msg.guildId!, msg.channelId, id) } : {}),
2188
+ }).catch(() => ({ covered: [] }) as { systemNote?: string });
2189
+ const built = buildDiscordUnifiedWake({
2190
+ guildId: msg.guildId ?? "",
2191
+ channelId: msg.channelId,
2192
+ parentChannelId: str(input.auth.attributes.parent_channel_id),
2193
+ isThread: isThreadChannelType(msg.channelType),
2194
+ addressing: input.addressing,
2195
+ threadEngaged: input.threadEngaged,
2196
+ wakeId,
2197
+ engagementTier: discordEngagementTier(domainKeyOf(input.partition)),
2198
+ tail,
2199
+ // 🔴 THE HYDRATION RIDES THE WAKE, NOT A PRE-RENDERED STRING (OPE-1749). `renderWake` holds
2200
+ // both slots — these lines and `tail` — so core de-duplicates them against each other and
2201
+ // spends the cap below that de-duplication, the same road every other surface takes.
2202
+ contextLines: input.contextLines,
2203
+ contextSource: input.contextSource,
2204
+ botName: msg.botName,
2205
+ catchUpUnavailable,
2206
+ trigger: {
2207
+ id: msg.id || "unknown",
2208
+ author: msg.author.username ?? msg.author.id,
2209
+ authorId: msg.author.id,
2210
+ sentAt: atIso,
2211
+ text: msg.content,
2212
+ trust,
2213
+ },
2214
+ });
2215
+ const rendered = renderWake(cover.systemNote ? { ...built, systemNote: built.systemNote ? `${built.systemNote}\n${cover.systemNote}` : cover.systemNote } : built);
2175
2216
  const domainSurfaceKey = domainKeyOf(input.partition);
2176
2217
  try {
2177
2218
  // THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719) — the INBOUND variant, the one Slack's front
@@ -2225,7 +2266,7 @@ export async function divertDiscordWakeToUnifiedWindow(input: {
2225
2266
  await auditSupervisorFailure(
2226
2267
  { org: deploymentOrg(), platform: input.partition.platform, workspace: input.partition.workspace, surface: domainSurfaceKey },
2227
2268
  "surface.wake_failed",
2228
- { message: String(err instanceof Error ? err.message : err).slice(0, 500), wakeId, addressing: input.addressing },
2269
+ { message: auditErrorDetail(err).error, wakeId, addressing: input.addressing },
2229
2270
  );
2230
2271
  throw err;
2231
2272
  }
@@ -2343,6 +2384,8 @@ export async function dispatchTurn(
2343
2384
  }
2344
2385
  }
2345
2386
  const partition: Partition = resolvePartition(ctxFromAuth(auth));
2387
+ // OPE-1940: the RESOLVED mode of the same surface, spread into every audit row below that names it.
2388
+ const auditMode = auditSurfaceModeFrom(ctxFromAuth(auth));
2346
2389
  const key = engagementKey(msg);
2347
2390
  const isDm = isDmChannel(msg.channelType);
2348
2391
  // A DM or a mention always wakes; only a plain guild message needs the (async) engagement lookup.
@@ -2395,6 +2438,7 @@ export async function dispatchTurn(
2395
2438
  platform: "discord",
2396
2439
  workspace: partition.workspace,
2397
2440
  surface: partition.surfaceKey,
2441
+ ...auditMode,
2398
2442
  principalId: msg.author.id,
2399
2443
  action: "discord.external_policy_suppressed",
2400
2444
  detail: { verdict: externalVerdict, channelId: msg.channelId, ...suppressionRendering(acked) },
@@ -2558,6 +2602,7 @@ export async function dispatchTurn(
2558
2602
  // which is what `partition` already carries — no fabricated guild id.
2559
2603
  workspace: partition.workspace,
2560
2604
  surface: partition.surfaceKey,
2605
+ ...auditMode,
2561
2606
  principalId: msg.author.id,
2562
2607
  action: "discord.dispatched",
2563
2608
  detail: { addressing: "direct-mention", dmWindow: true, channelId: msg.channelId },
@@ -2638,6 +2683,7 @@ export async function dispatchTurn(
2638
2683
  platform: "discord",
2639
2684
  workspace: msg.guildId ?? "unknown",
2640
2685
  surface: partition.surfaceKey,
2686
+ ...auditMode,
2641
2687
  principalId: msg.author.id,
2642
2688
  action: "discord.dispatched",
2643
2689
  detail: { addressing, singleWindow: true, channelId: msg.channelId },
@@ -3007,6 +3053,15 @@ export default withEndsTurnFinalChannel(defineChannel<DiscordGatewayState, Disco
3007
3053
  return new Response("ok", { status: 200 });
3008
3054
  }
3009
3055
 
3056
+ // Discord voice meetings (OPE-2473) — the relay's VOICE_SYNC. Like the heartbeat it mints no session and no
3057
+ // wake. It is answered INLINE, because the answer IS the payload: the voice channels the relay must be in
3058
+ // (core meetings/discord-voice.ts). The relay's report (credentials, members) is stored for the meeting box.
3059
+ const voiceSync = parseVoiceSync(json);
3060
+ if (voiceSync) {
3061
+ const answer = await syncDiscordVoice(deploymentOrg(), voiceSync);
3062
+ return new Response(JSON.stringify(answer), { status: 200, headers: { "content-type": "application/json" } });
3063
+ }
3064
+
3010
3065
  // Inbound reaction — a distinct relay payload shape. Handled before the message parse:
3011
3066
  // ingest async (telemetry always, a durable feedback row on a bot reply), ack immediately.
3012
3067
  // A reaction never starts a turn — but since OPE-418 a ✋ can STOP one, which is why
@@ -0,0 +1,46 @@
1
+ // The Discord invite lookup for meeting links (OPE-2473): `GET /invites/{code}` with the bot token, registered with
2
+ // core's meeting invite seam (core meetings/invite.ts) from registerDiscordProvider(). A person who uses Discord's
3
+ // "Invite to voice channel" pastes `https://discord.gg/<code>`; this answers which server and channel the code
4
+ // opens and what kind of channel it is. Core then builds the channel's own link and runs every usual check on it.
5
+ //
6
+ // The answer is Discord's data and core treats it as untrusted: core re-parses the ids through the same strict
7
+ // link parse a pasted link meets. The bot token is a SECRET — header only, never logged. No cache: an invite is
8
+ // looked up once per ask, and an expired code must read as expired.
9
+
10
+ import type { MeetingInviteLookup, MeetingInviteResolver } from "@open-everywhere/core/agent/adapter-api.js";
11
+
12
+ /** Discord's channel types for a voice room: GUILD_VOICE (2) and GUILD_STAGE_VOICE (13). */
13
+ const GUILD_VOICE = 2;
14
+ const GUILD_STAGE_VOICE = 13;
15
+ /** A Discord id (a snowflake): 17 to 20 digits. */
16
+ const SNOWFLAKE = /^\d{17,20}$/;
17
+
18
+ export interface DiscordInviteDeps {
19
+ readonly fetchFn?: typeof fetch;
20
+ readonly token?: string;
21
+ }
22
+
23
+ /** Look one invite code up. NEVER throws: every failure is a `found: false` answer core turns into a plain reason. */
24
+ export async function lookUpDiscordInvite(code: string, deps: DiscordInviteDeps = {}): Promise<MeetingInviteLookup> {
25
+ const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
26
+ if (!token) return { found: false, why: "unreachable" };
27
+ try {
28
+ const doFetch = deps.fetchFn ?? fetch;
29
+ const res = await doFetch(`https://discord.com/api/v10/invites/${encodeURIComponent(code)}`, { headers: { authorization: `Bot ${token}` } });
30
+ // Discord answers an expired, revoked or mistyped code with 404 (Unknown Invite).
31
+ if (res.status === 404) return { found: false, why: "unknown" };
32
+ if (!res.ok) return { found: false, why: "unreachable" };
33
+ const body = (await res.json()) as { guild?: { id?: unknown } | null; channel?: { id?: unknown; type?: unknown } | null };
34
+ const guildId = body.guild?.id;
35
+ const channelId = body.channel?.id;
36
+ if (typeof guildId !== "string" || !SNOWFLAKE.test(guildId) || typeof channelId !== "string" || !SNOWFLAKE.test(channelId)) return { found: false, why: "no-channel" };
37
+ const type = body.channel?.type;
38
+ const channelKind = type === GUILD_VOICE ? "voice" : type === GUILD_STAGE_VOICE ? "stage" : "other";
39
+ return { found: true, guildId, channelId, channelKind };
40
+ } catch {
41
+ return { found: false, why: "unreachable" };
42
+ }
43
+ }
44
+
45
+ /** The resolver core's seam holds: the live lookup, with the live token and fetch. */
46
+ export const discordInviteResolver: MeetingInviteResolver = (code) => lookUpDiscordInvite(code);
@@ -80,6 +80,17 @@ export const discordWakeOriginCodec: WakeOriginCodec<DiscordOrigin> = {
80
80
  // which is the fail-closed direction and byte-identical to the behaviour this replaced.
81
81
  isThreadScoped: (origin) => !isDmOrigin(origin) && origin.thread === true,
82
82
  containerId: (origin) => origin.channelId,
83
+ // THE THREAD A REPLY OPENS (OPE-2558, core routines/report-thread.ts). A Discord thread started from a
84
+ // message takes that message's id as its own channel id, so a thread a person opens off a top-level
85
+ // post is the guild channel `<post id>` with `thread: true` (the shape the gateway mints for a message
86
+ // in it). A post that is already in a thread answers undefined, because its own `threadRef` is the key.
87
+ // A DM has no threads. An origin with `autoThreadName` answers undefined: its arm may move the post
88
+ // into a thread of its own, so the post id does not name the thread a reply opens. A plain Discord
89
+ // reply (a message reference in the channel) is not a thread, so only a thread off the post counts.
90
+ replyThreadUnder: (origin, messageId) => {
91
+ if (isDmOrigin(origin) || origin.thread === true || origin.autoThreadName !== undefined) return undefined;
92
+ return { platform: "discord", guildId: origin.guildId, channelId: messageId, thread: true };
93
+ },
83
94
  // THE PLACEMENT AXIS (OPE-1826). Discord is the SECOND surface whose post placement sits OUTSIDE
84
95
  // its conversation identity, and until this member existed nothing said so: `threadRef` keys on the
85
96
  // channel id alone (a Discord thread IS its own channel id), while the post arm hangs the answer on
@@ -33,6 +33,7 @@ import {
33
33
  agentDisplayName,
34
34
  oeCommandName,
35
35
  oeSlash,
36
+ parseOeCommandTokens,
36
37
  runOeCommand,
37
38
  type AttachSessionForCancel,
38
39
  type BindableContext,
@@ -48,9 +49,16 @@ const RESPONSE_CHANNEL_MESSAGE = 4; // CHANNEL_MESSAGE_WITH_SOURCE — a fresh r
48
49
  const EPHEMERAL_FLAG = 64; // only the invoking user sees the reply
49
50
 
50
51
  function ephemeral(content: string): DiscordInteractionResponse {
51
- return { type: RESPONSE_CHANNEL_MESSAGE, data: { content, flags: EPHEMERAL_FLAG } };
52
+ return { type: RESPONSE_CHANNEL_MESSAGE, data: { content: content.length > 0 ? content : DISCORD_CARD_ACK, flags: EPHEMERAL_FLAG } };
52
53
  }
53
54
 
55
+ /**
56
+ * The private acknowledgement for a command whose card IS the answer (OPE-2490, core's
57
+ * `CARD_IS_THE_ANSWER`). Discord must answer an interaction with content, or it shows "The application
58
+ * did not respond". So the answer is the smallest one there is: it says nothing the card says.
59
+ */
60
+ const DISCORD_CARD_ACK = "👍";
61
+
54
62
  // --- defensive wire-JSON probing (untrusted body, post-signature-check) ---------------------
55
63
  // Mirrors approvals/discord-flow.ts's parseDiscordComponentClick posture exactly: this is
56
64
  // attacker-shaped JSON reaching a public URL (Discord relays whatever a client sends), so
@@ -130,6 +138,20 @@ export function parseDiscordOeCommandOptions(rawOptions: unknown): OeCommand {
130
138
  }
131
139
  return { kind: "rate", score };
132
140
  }
141
+ if (name === "cover") {
142
+ // OPE-2534: the out-of-office cover switch. Core's own grammar parses it (commands/cover.ts), so `/oe cover on until
143
+ // 2026-10-20` means on Discord what it means on Slack and Teams, and the same date check answers.
144
+ const sub = optArray(first.options)[0];
145
+ const subName = sub ? str(sub.name) : undefined;
146
+ const until = str(findOpt(optArray(sub?.options), "until")?.value);
147
+ return parseOeCommandTokens(["cover", ...(subName ? [subName] : []), ...(subName === "on" && until ? ["until", until] : [])]);
148
+ }
149
+ if (name === "suggest") {
150
+ // OPE-2550: the room's stop for routine suggestions. Core's own grammar parses it (commands/suggest.ts).
151
+ const sub = optArray(first.options)[0];
152
+ const subName = sub ? str(sub.name) : undefined;
153
+ return parseOeCommandTokens(["suggest", ...(subName ? [subName] : [])]);
154
+ }
133
155
  if (name === "budget") {
134
156
  const sub = optArray(first.options)[0];
135
157
  const subName = sub ? str(sub.name) : undefined;
@@ -226,6 +248,7 @@ export async function handleDiscordOeCommandInteraction(
226
248
  // note on why this file doesn't import eve's channel module for two integers).
227
249
  const SUB_COMMAND = 1;
228
250
  const SUB_COMMAND_GROUP = 2;
251
+ const OPTION_STRING = 3;
229
252
  const OPTION_INTEGER = 4;
230
253
  const OPTION_NUMBER = 10;
231
254
  const CHAT_INPUT = 1;
@@ -299,6 +322,35 @@ export function buildOeDiscordCommandDefinition(): Record<string, unknown> {
299
322
  },
300
323
  ],
301
324
  },
325
+ // OPE-2534: out-of-office cover. A SUB_COMMAND_GROUP, because Discord needs a concrete subcommand: `status` is the
326
+ // bare `/oe cover` of Slack and Teams, and `on` takes the optional end date core's grammar reads.
327
+ {
328
+ name: "cover",
329
+ description: "While you are away, I answer questions people ask you, marked as cover.",
330
+ type: SUB_COMMAND_GROUP,
331
+ options: [
332
+ {
333
+ name: "on",
334
+ description: "Turn cover on (from your DM with me).",
335
+ type: SUB_COMMAND,
336
+ options: [{ name: "until", description: "The first day you are back, as YYYY-MM-DD.", type: OPTION_STRING, required: false }],
337
+ },
338
+ { name: "off", description: "Turn cover off. Your recap follows in your DM.", type: SUB_COMMAND },
339
+ { name: "status", description: "Show whether cover is on.", type: SUB_COMMAND },
340
+ ],
341
+ },
342
+ // OPE-2550: the room's stop for the routine suggestions room discovery posts. A SUB_COMMAND_GROUP for the same
343
+ // reason as `cover`: `status` is the bare `/oe suggest` of Slack and Teams.
344
+ {
345
+ name: "suggest",
346
+ description: "Stop or allow my routine suggestions in this channel or thread.",
347
+ type: SUB_COMMAND_GROUP,
348
+ options: [
349
+ { name: "off", description: "Stop my routine suggestions in this channel or thread.", type: SUB_COMMAND },
350
+ { name: "on", description: "Allow them again (members only).", type: SUB_COMMAND },
351
+ { name: "status", description: "Show whether they are on in this channel or thread.", type: SUB_COMMAND },
352
+ ],
353
+ },
302
354
  { name: "help", description: `List the ${oeSlash()} commands.`, type: SUB_COMMAND },
303
355
  ],
304
356
  };