@open-everywhere/provider-discord 0.6.0 → 0.7.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 (38) hide show
  1. package/agent/approvals/discord-card.ts +32 -38
  2. package/agent/channels/discord-channel-info.ts +43 -15
  3. package/agent/channels/discord-friendly-names.ts +28 -0
  4. package/agent/channels/discord-front.ts +7 -3
  5. package/agent/channels/discord-gateway.ts +3 -5
  6. package/agent/channels/discord-rest.ts +35 -2
  7. package/agent/commands/discord-commands.ts +11 -11
  8. package/agent/docs-generated.ts +1 -1
  9. package/agent/register.ts +13 -0
  10. package/dist/agent/approvals/discord-card.d.ts +13 -14
  11. package/dist/agent/approvals/discord-card.d.ts.map +1 -1
  12. package/dist/agent/approvals/discord-card.js +23 -36
  13. package/dist/agent/approvals/discord-card.js.map +1 -1
  14. package/dist/agent/channels/discord-channel-info.d.ts +3 -1
  15. package/dist/agent/channels/discord-channel-info.d.ts.map +1 -1
  16. package/dist/agent/channels/discord-channel-info.js +40 -13
  17. package/dist/agent/channels/discord-channel-info.js.map +1 -1
  18. package/dist/agent/channels/discord-friendly-names.d.ts +7 -0
  19. package/dist/agent/channels/discord-friendly-names.d.ts.map +1 -0
  20. package/dist/agent/channels/discord-friendly-names.js +27 -0
  21. package/dist/agent/channels/discord-friendly-names.js.map +1 -0
  22. package/dist/agent/channels/discord-front.d.ts.map +1 -1
  23. package/dist/agent/channels/discord-front.js +7 -3
  24. package/dist/agent/channels/discord-front.js.map +1 -1
  25. package/dist/agent/channels/discord-gateway.js +3 -2
  26. package/dist/agent/channels/discord-gateway.js.map +1 -1
  27. package/dist/agent/channels/discord-rest.d.ts.map +1 -1
  28. package/dist/agent/channels/discord-rest.js +39 -2
  29. package/dist/agent/channels/discord-rest.js.map +1 -1
  30. package/dist/agent/commands/discord-commands.d.ts.map +1 -1
  31. package/dist/agent/commands/discord-commands.js +11 -12
  32. package/dist/agent/commands/discord-commands.js.map +1 -1
  33. package/dist/agent/docs-generated.js +1 -1
  34. package/dist/agent/docs-generated.js.map +1 -1
  35. package/dist/agent/register.d.ts.map +1 -1
  36. package/dist/agent/register.js +12 -1
  37. package/dist/agent/register.js.map +1 -1
  38. package/package.json +6 -2
@@ -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 */
@@ -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
  }
@@ -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
  /**
@@ -54,7 +54,7 @@ import { threadParentChannelId } from "./discord-channel-info.js";
54
54
  import { splitForDiscord } from "./discord-post.js";
55
55
  import { discordFetch, type DiscordFetchDeps } from "./discord-rest.js";
56
56
  import { RateLimitExhaustedError, type RetryPolicy } from "@open-everywhere/core/agent/adapter-api.js";
57
- import { withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
57
+ import { TURN_FAILED_TEXT, withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
58
58
  import {
59
59
  finalMessageAction,
60
60
  nameDropPattern,
@@ -3114,10 +3114,8 @@ export default withEndsTurnFinalChannel(defineChannel<DiscordGatewayState, Disco
3114
3114
  openTypingGate(channel.channelId); // turn over — never leak a closed gate into the next
3115
3115
  const detail = (data as { error?: { message?: string } } | undefined)?.error?.message;
3116
3116
  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
- );
3117
+ // OPE-2486 — the person reads one plain line; the error detail stays in the log line above.
3118
+ await postDiscordReply(channel.channelId, `⚠️ ${TURN_FAILED_TEXT}`);
3121
3119
  },
3122
3120
  // Deliver the turn's final reply back to Discord AND reconcile the 👀→✅ swap (auto-threaded
3123
3121
  // mentions only). finalMessageAction (the shared, unit-tested decision slack-events.ts owns)
@@ -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,6 +26,7 @@
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,
@@ -124,7 +125,7 @@ export function parseDiscordOeCommandOptions(rawOptions: unknown): OeCommand {
124
125
  if (score === undefined || !Number.isInteger(score) || score < MODEL_AB_RATING_MIN || score > MODEL_AB_RATING_MAX) {
125
126
  return {
126
127
  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)}).`,
128
+ message: `Rate with a whole number from ${MODEL_AB_RATING_MIN} to ${MODEL_AB_RATING_MAX}, for example \`${oeSlash("rate 4")}\`.`,
128
129
  };
129
130
  }
130
131
  return { kind: "rate", score };
@@ -140,14 +141,14 @@ export function parseDiscordOeCommandOptions(rawOptions: unknown): OeCommand {
140
141
  if (usd === undefined || usd <= 0) {
141
142
  return {
142
143
  kind: "error",
143
- message: `"${oeSlash("budget raise")}" needs a positive "usd" amount (got ${JSON.stringify(usdOpt?.value)}).`,
144
+ message: `Add an amount in dollars, for example \`${oeSlash("budget raise 25")}\`.`,
144
145
  };
145
146
  }
146
147
  return { kind: "budget-raise", usd };
147
148
  }
148
- return { kind: "error", message: `Unknown "budget" subcommand "${subName}".` };
149
+ return { kind: "error", message: `I don't know "${subName}". Try \`${oeSlash("budget")}\`, \`${oeSlash("budget raise 25")}\` or \`${oeSlash("budget clear")}\`.` };
149
150
  }
150
- return { kind: "error", message: `Unknown "${oeSlash()}" subcommand "${name ?? "?"}" — try "${oeSlash("help")}".` };
151
+ return { kind: "error", message: `I don't know "${name ?? "that"}". Try \`${oeSlash("help")}\` to see what I can do.` };
151
152
  }
152
153
 
153
154
  interface ParsedOeInvocation {
@@ -174,11 +175,11 @@ export function parseOeCommandInteraction(raw: unknown): ParsedOeInvocation | {
174
175
  const userId = (memberUser ? str(memberUser["id"]) : undefined) ?? (user ? str(user["id"]) : undefined);
175
176
  const isBot = memberUser?.["bot"] === true || user?.["bot"] === true;
176
177
  if (!userId || isBot) {
177
- return { error: "Couldn't identify who invoked this — commands can't be run by a bot." };
178
+ return { error: "Only a person can use this command." };
178
179
  }
179
180
  const channelId = str(raw["channel_id"]);
180
181
  if (!channelId) {
181
- return { error: "Couldn't resolve this channel's id — try again from a real channel or DM." };
182
+ return { error: "I can't do that here. Try it in a channel or a direct message." };
182
183
  }
183
184
  const guildId = str(raw["guild_id"]);
184
185
  const channelType = channelTypeFromRaw(raw);
@@ -212,12 +213,11 @@ export async function handleDiscordOeCommandInteraction(
212
213
  const text = await runOeCommand(parsed.command, binding, attachSession ? { attachSession } : {});
213
214
  return ephemeral(text);
214
215
  } 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.
216
+ // A PersonFacingError renders as-is; anything else (a store or network failure, a planner's
217
+ // model-facing refusal) renders the generic line, and its message goes to the log only
218
+ // (OPE-2486). Either way, never throw out of this function.
219
219
  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)}`);
220
+ return ephemeral(commandFailureText(err));
221
221
  }
222
222
  }
223
223