@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
@@ -48,13 +48,14 @@
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";
54
55
  import { splitForDiscord } from "./discord-post.js";
55
56
  import { discordFetch, type DiscordFetchDeps } from "./discord-rest.js";
56
57
  import { RateLimitExhaustedError, type RetryPolicy } from "@open-everywhere/core/agent/adapter-api.js";
57
- import { withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
58
+ import { TURN_FAILED_TEXT, withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
58
59
  import {
59
60
  finalMessageAction,
60
61
  nameDropPattern,
@@ -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
@@ -3114,10 +3169,8 @@ export default withEndsTurnFinalChannel(defineChannel<DiscordGatewayState, Disco
3114
3169
  openTypingGate(channel.channelId); // turn over — never leak a closed gate into the next
3115
3170
  const detail = (data as { error?: { message?: string } } | undefined)?.error?.message;
3116
3171
  log(`turn failed (channel ${channel.channelId || "?"})${detail ? `: ${detail}` : ""}`);
3117
- await postDiscordReply(
3118
- channel.channelId,
3119
- "⚠️ I hit an error while handling that and couldn't finish. Please try again — and if it keeps failing, check the deployment logs.",
3120
- );
3172
+ // OPE-2486 — the person reads one plain line; the error detail stays in the log line above.
3173
+ await postDiscordReply(channel.channelId, `⚠️ ${TURN_FAILED_TEXT}`);
3121
3174
  },
3122
3175
  // Deliver the turn's final reply back to Discord AND reconcile the 👀→✅ swap (auto-threaded
3123
3176
  // mentions only). finalMessageAction (the shared, unit-tested decision slack-events.ts owns)
@@ -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
@@ -29,7 +29,38 @@
29
29
  // `POST /channels/{id}/messages`) that rides the NAMED `RateLimitExhaustedError`; keep ids out of it,
30
30
  // so every field on that error stays safe to log.
31
31
 
32
- import { withRetryAfter, type RetryPolicy } from "@open-everywhere/core/agent/adapter-api.js";
32
+ import { friendlyDiscordBody, withRetryAfter, type RetryPolicy } from "@open-everywhere/core/agent/adapter-api.js";
33
+
34
+ /**
35
+ * THE FRIENDLY-NAMES GUARD (OPE-2482). A message write (create, edit, webhook or interaction follow-up)
36
+ * renders every raw id in its content, embeds and component labels as a mention, a name or plain words
37
+ * before it leaves. A JSON string body and a multipart `payload_json` are both covered; any other
38
+ * operation, and a body this cannot parse, goes out unchanged.
39
+ */
40
+ async function friendlyDiscordInit(operation: string, init: RequestInit): Promise<RequestInit> {
41
+ if (!/^(?:POST|PATCH) /.test(operation) || !/\/messages\b|\/callback\b|^POST \/webhooks\//.test(operation)) return init;
42
+ const render = async (json: string): Promise<string | undefined> => {
43
+ try {
44
+ return JSON.stringify(await friendlyDiscordBody(JSON.parse(json) as unknown));
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ };
49
+ if (typeof init.body === "string") {
50
+ const body = await render(init.body);
51
+ return body === undefined ? init : { ...init, body };
52
+ }
53
+ if (init.body instanceof FormData) {
54
+ const payload = init.body.get("payload_json");
55
+ if (typeof payload !== "string") return init;
56
+ const body = await render(payload);
57
+ if (body === undefined) return init;
58
+ const form = new FormData();
59
+ for (const [k, v] of init.body.entries()) form.append(k, k === "payload_json" ? body : v);
60
+ return { ...init, body: form };
61
+ }
62
+ return init;
63
+ }
33
64
 
34
65
  /** The provider surface name on the NAMED rate-limit failure. */
35
66
  export const DISCORD_SURFACE = "discord";
@@ -75,7 +106,9 @@ export async function discordBodyRetryAfterMs(res: Response): Promise<number | u
75
106
  * applied).
76
107
  */
77
108
  export function discordFetch(operation: string, url: string, init: RequestInit, deps: DiscordFetchDeps = {}): Promise<Response> {
78
- const send = (): Promise<Response> => (deps.fetch ?? (globalThis.fetch as DiscordFetchLike))(url, init);
109
+ // The guarded body is computed once and re-sent unchanged by every retry.
110
+ const guarded = friendlyDiscordInit(operation, init);
111
+ const send = async (): Promise<Response> => (deps.fetch ?? (globalThis.fetch as DiscordFetchLike))(url, await guarded);
79
112
  return withRetryAfter(send, {
80
113
  surface: DISCORD_SURFACE,
81
114
  operation,
@@ -26,12 +26,14 @@
26
26
  // channels/discord-front.ts's classifyDiscordInteraction.
27
27
 
28
28
  import {
29
+ commandFailureText,
29
30
  bindFrom,
30
31
  MODEL_AB_RATING_MAX,
31
32
  MODEL_AB_RATING_MIN,
32
33
  agentDisplayName,
33
34
  oeCommandName,
34
35
  oeSlash,
36
+ parseOeCommandTokens,
35
37
  runOeCommand,
36
38
  type AttachSessionForCancel,
37
39
  type BindableContext,
@@ -47,9 +49,16 @@ const RESPONSE_CHANNEL_MESSAGE = 4; // CHANNEL_MESSAGE_WITH_SOURCE — a fresh r
47
49
  const EPHEMERAL_FLAG = 64; // only the invoking user sees the reply
48
50
 
49
51
  function ephemeral(content: string): DiscordInteractionResponse {
50
- 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 } };
51
53
  }
52
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
+
53
62
  // --- defensive wire-JSON probing (untrusted body, post-signature-check) ---------------------
54
63
  // Mirrors approvals/discord-flow.ts's parseDiscordComponentClick posture exactly: this is
55
64
  // attacker-shaped JSON reaching a public URL (Discord relays whatever a client sends), so
@@ -124,11 +133,25 @@ export function parseDiscordOeCommandOptions(rawOptions: unknown): OeCommand {
124
133
  if (score === undefined || !Number.isInteger(score) || score < MODEL_AB_RATING_MIN || score > MODEL_AB_RATING_MAX) {
125
134
  return {
126
135
  kind: "error",
127
- message: `"${oeSlash("rate")}" needs a whole "score" from ${MODEL_AB_RATING_MIN} to ${MODEL_AB_RATING_MAX} (got ${JSON.stringify(scoreOpt?.value)}).`,
136
+ message: `Rate with a whole number from ${MODEL_AB_RATING_MIN} to ${MODEL_AB_RATING_MAX}, for example \`${oeSlash("rate 4")}\`.`,
128
137
  };
129
138
  }
130
139
  return { kind: "rate", score };
131
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
+ }
132
155
  if (name === "budget") {
133
156
  const sub = optArray(first.options)[0];
134
157
  const subName = sub ? str(sub.name) : undefined;
@@ -140,14 +163,14 @@ export function parseDiscordOeCommandOptions(rawOptions: unknown): OeCommand {
140
163
  if (usd === undefined || usd <= 0) {
141
164
  return {
142
165
  kind: "error",
143
- message: `"${oeSlash("budget raise")}" needs a positive "usd" amount (got ${JSON.stringify(usdOpt?.value)}).`,
166
+ message: `Add an amount in dollars, for example \`${oeSlash("budget raise 25")}\`.`,
144
167
  };
145
168
  }
146
169
  return { kind: "budget-raise", usd };
147
170
  }
148
- return { kind: "error", message: `Unknown "budget" subcommand "${subName}".` };
171
+ return { kind: "error", message: `I don't know "${subName}". Try \`${oeSlash("budget")}\`, \`${oeSlash("budget raise 25")}\` or \`${oeSlash("budget clear")}\`.` };
149
172
  }
150
- return { kind: "error", message: `Unknown "${oeSlash()}" subcommand "${name ?? "?"}" — try "${oeSlash("help")}".` };
173
+ return { kind: "error", message: `I don't know "${name ?? "that"}". Try \`${oeSlash("help")}\` to see what I can do.` };
151
174
  }
152
175
 
153
176
  interface ParsedOeInvocation {
@@ -174,11 +197,11 @@ export function parseOeCommandInteraction(raw: unknown): ParsedOeInvocation | {
174
197
  const userId = (memberUser ? str(memberUser["id"]) : undefined) ?? (user ? str(user["id"]) : undefined);
175
198
  const isBot = memberUser?.["bot"] === true || user?.["bot"] === true;
176
199
  if (!userId || isBot) {
177
- return { error: "Couldn't identify who invoked this — commands can't be run by a bot." };
200
+ return { error: "Only a person can use this command." };
178
201
  }
179
202
  const channelId = str(raw["channel_id"]);
180
203
  if (!channelId) {
181
- return { error: "Couldn't resolve this channel's id — try again from a real channel or DM." };
204
+ return { error: "I can't do that here. Try it in a channel or a direct message." };
182
205
  }
183
206
  const guildId = str(raw["guild_id"]);
184
207
  const channelType = channelTypeFromRaw(raw);
@@ -212,12 +235,11 @@ export async function handleDiscordOeCommandInteraction(
212
235
  const text = await runOeCommand(parsed.command, binding, attachSession ? { attachSession } : {});
213
236
  return ephemeral(text);
214
237
  } catch (err) {
215
- // runOeCommand's budget-raise/clear branches can throw a user-facing validation error
216
- // (e.g. "propose from a DM needs an explicit scope") — surface it verbatim; anything else
217
- // (a genuinely unexpected store/network failure) gets a generic fail-soft note. Either way,
218
- // never throw out of this function.
238
+ // A PersonFacingError renders as-is; anything else (a store or network failure, a planner's
239
+ // model-facing refusal) renders the generic line, and its message goes to the log only
240
+ // (OPE-2486). Either way, never throw out of this function.
219
241
  console.error(`[open-everywhere:discord-commands] "${oeSlash()}" command failed softly: ${String(err)}`);
220
- return ephemeral(`Couldn't complete that: ${err instanceof Error ? err.message : String(err)}`);
242
+ return ephemeral(commandFailureText(err));
221
243
  }
222
244
  }
223
245
 
@@ -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
  };