@open-everywhere/provider-discord 0.5.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.
- package/agent/approvals/discord-card.ts +35 -40
- package/agent/channels/discord-channel-info.ts +43 -15
- package/agent/channels/discord-friendly-names.ts +28 -0
- package/agent/channels/discord-front.ts +7 -3
- package/agent/channels/discord-gateway.ts +3 -5
- package/agent/channels/discord-rest.ts +35 -2
- package/agent/commands/discord-commands.ts +11 -11
- package/agent/docs-generated.ts +1 -1
- package/agent/register.ts +13 -0
- package/dist/agent/approvals/discord-card.d.ts +14 -15
- package/dist/agent/approvals/discord-card.d.ts.map +1 -1
- package/dist/agent/approvals/discord-card.js +26 -38
- package/dist/agent/approvals/discord-card.js.map +1 -1
- package/dist/agent/channels/discord-channel-info.d.ts +3 -1
- package/dist/agent/channels/discord-channel-info.d.ts.map +1 -1
- package/dist/agent/channels/discord-channel-info.js +40 -13
- package/dist/agent/channels/discord-channel-info.js.map +1 -1
- package/dist/agent/channels/discord-friendly-names.d.ts +7 -0
- package/dist/agent/channels/discord-friendly-names.d.ts.map +1 -0
- package/dist/agent/channels/discord-friendly-names.js +27 -0
- package/dist/agent/channels/discord-friendly-names.js.map +1 -0
- package/dist/agent/channels/discord-front.d.ts.map +1 -1
- package/dist/agent/channels/discord-front.js +7 -3
- package/dist/agent/channels/discord-front.js.map +1 -1
- package/dist/agent/channels/discord-gateway.js +3 -2
- package/dist/agent/channels/discord-gateway.js.map +1 -1
- package/dist/agent/channels/discord-rest.d.ts.map +1 -1
- package/dist/agent/channels/discord-rest.js +39 -2
- package/dist/agent/channels/discord-rest.js.map +1 -1
- package/dist/agent/commands/discord-commands.d.ts.map +1 -1
- package/dist/agent/commands/discord-commands.js +11 -12
- package/dist/agent/commands/discord-commands.js.map +1 -1
- package/dist/agent/docs-generated.js +1 -1
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/register.d.ts.map +1 -1
- package/dist/agent/register.js +12 -1
- package/dist/agent/register.js.map +1 -1
- 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 —
|
|
10
|
-
// code-rendered
|
|
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 {
|
|
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,21 +120,19 @@ 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
|
|
123
|
-
* 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
|
|
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.
|
|
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
|
|
128
|
-
const
|
|
132
|
+
const [headline, ...rest] = pendingCardLines(layout);
|
|
133
|
+
const content = [`**${headline}**`, ...rest, `-# ${cardFooter(footerRequester(row, "discord"), layout.expiry)}`].join("\n");
|
|
134
|
+
// A Discord button sends its click with no confirmation first, so the rule option (OPE-2456) is left out here.
|
|
135
|
+
const buttons: DiscordComponent[] = optionsWithoutConfirm(layout.options).map((o) => ({
|
|
129
136
|
type: COMPONENT_BUTTON,
|
|
130
137
|
style: OPTION_STYLE[o.tone],
|
|
131
138
|
label: o.label,
|
|
@@ -145,18 +152,18 @@ export function pendingDiscordCard(row: PendingActionRow, summaryLines: readonly
|
|
|
145
152
|
* `approval.judge.contradicted` row the contradiction breaker counts.
|
|
146
153
|
*
|
|
147
154
|
* NOT AN APPROVAL CARD, and the render says so: no Approve, no Reject, no expiry line. The act is
|
|
148
|
-
* already done, so this is a review surface, not a decision surface.
|
|
149
|
-
* `cardBody` — that helper's header reads "Approval requested", which is the one thing this message
|
|
150
|
-
* must never say.
|
|
155
|
+
* already done, so this is a review surface, not a decision surface.
|
|
151
156
|
*
|
|
152
|
-
* THE COPY IS THE SLACK COPY, word for word (
|
|
153
|
-
* on two platforms must not be told different things about the same
|
|
154
|
-
* 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.
|
|
155
161
|
*/
|
|
156
|
-
export function judgeLiftNoticeDiscordCard(row: PendingActionRow,
|
|
157
|
-
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 {
|
|
158
163
|
return {
|
|
159
|
-
content:
|
|
164
|
+
content: judgeLiftLines(row, outcomeLine)
|
|
165
|
+
.map((line, i, all) => (i === 0 ? `**${line}**` : i === all.length - 1 ? `-# ${line}` : line))
|
|
166
|
+
.join("\n"),
|
|
160
167
|
components: [
|
|
161
168
|
{
|
|
162
169
|
type: COMPONENT_ACTION_ROW,
|
|
@@ -164,7 +171,7 @@ export function judgeLiftNoticeDiscordCard(row: PendingActionRow, summaryLines:
|
|
|
164
171
|
{
|
|
165
172
|
type: COMPONENT_BUTTON,
|
|
166
173
|
style: BUTTON_STYLE_DANGER,
|
|
167
|
-
label:
|
|
174
|
+
label: JUDGE_LIFT_BUTTON,
|
|
168
175
|
custom_id: buildDiscordApprovalCustomId(row, "contradict"),
|
|
169
176
|
},
|
|
170
177
|
],
|
|
@@ -174,25 +181,13 @@ export function judgeLiftNoticeDiscordCard(row: PendingActionRow, summaryLines:
|
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
/**
|
|
177
|
-
* The card once a decision has landed:
|
|
178
|
-
* type-7 UPDATE_MESSAGE response replaces the whole message, so an empty row strips them)
|
|
179
|
-
*
|
|
180
|
-
* CAS-decided — this function never decides anything
|
|
181
|
-
* 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.
|
|
182
188
|
*/
|
|
183
|
-
export function decidedDiscordCard(row: PendingActionRow,
|
|
184
|
-
|
|
185
|
-
row.status === "approved"
|
|
186
|
-
? `✅ Approved by <@${row.decidedBy}> · ${outcomeLine}`
|
|
187
|
-
: row.status === "rejected"
|
|
188
|
-
? // A kind whose DECLINE is itself durable returns a line saying what the decline recorded
|
|
189
|
-
// (approvals/types.ts ActionKind.declined). Every other kind's reject passes the literal
|
|
190
|
-
// "rejected" and renders byte-for-byte what it always did. Kept in step with card.ts's
|
|
191
|
-
// decidedCardBlocks by hand, exactly like the three status lines above it — a Discord card
|
|
192
|
-
// that dropped the line would leave a clicker believing a decline was remembered.
|
|
193
|
-
`❌ Rejected by <@${row.decidedBy}>${outcomeLine === "rejected" ? "" : ` · ${outcomeLine}`}`
|
|
194
|
-
: "⏰ Expired";
|
|
195
|
-
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: [] };
|
|
196
191
|
}
|
|
197
192
|
|
|
198
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
|
|
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.
|
|
13
|
-
// cache note)
|
|
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
|
|
34
|
-
// settled result (
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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)
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
3118
|
-
|
|
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
|
-
|
|
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: `
|
|
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: `
|
|
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: `
|
|
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: `
|
|
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: "
|
|
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: "
|
|
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
|
-
//
|
|
216
|
-
//
|
|
217
|
-
// (
|
|
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(
|
|
220
|
+
return ephemeral(commandFailureText(err));
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|