@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.
- package/agent/approvals/discord-admin.ts +121 -0
- package/agent/approvals/discord-card.ts +32 -38
- package/agent/approvals/discord-flow.ts +18 -6
- package/agent/channels/discord-channel-info.ts +43 -15
- package/agent/channels/discord-dispatch-target.ts +36 -1
- package/agent/channels/discord-events.ts +3 -2
- package/agent/channels/discord-friendly-names.ts +28 -0
- package/agent/channels/discord-front.ts +7 -3
- package/agent/channels/discord-gateway.ts +88 -35
- package/agent/channels/discord-invite.ts +46 -0
- package/agent/channels/discord-origin-codec.ts +11 -0
- package/agent/channels/discord-rest.ts +35 -2
- package/agent/commands/discord-commands.ts +64 -12
- package/agent/docs-generated.ts +506 -2
- package/agent/register.ts +21 -1
- package/agent/surface-parity.ts +3 -0
- package/dist/agent/approvals/discord-admin.d.ts +31 -0
- package/dist/agent/approvals/discord-admin.d.ts.map +1 -1
- package/dist/agent/approvals/discord-admin.js +107 -0
- package/dist/agent/approvals/discord-admin.js.map +1 -1
- package/dist/agent/approvals/discord-card.d.ts +13 -14
- package/dist/agent/approvals/discord-card.d.ts.map +1 -1
- package/dist/agent/approvals/discord-card.js +23 -36
- package/dist/agent/approvals/discord-card.js.map +1 -1
- package/dist/agent/approvals/discord-flow.d.ts.map +1 -1
- package/dist/agent/approvals/discord-flow.js +18 -7
- package/dist/agent/approvals/discord-flow.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-dispatch-target.d.ts +17 -1
- package/dist/agent/channels/discord-dispatch-target.d.ts.map +1 -1
- package/dist/agent/channels/discord-dispatch-target.js +35 -1
- package/dist/agent/channels/discord-dispatch-target.js.map +1 -1
- package/dist/agent/channels/discord-events.d.ts +3 -2
- package/dist/agent/channels/discord-events.d.ts.map +1 -1
- package/dist/agent/channels/discord-events.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.d.ts +6 -0
- package/dist/agent/channels/discord-gateway.d.ts.map +1 -1
- package/dist/agent/channels/discord-gateway.js +61 -8
- package/dist/agent/channels/discord-gateway.js.map +1 -1
- package/dist/agent/channels/discord-invite.d.ts +10 -0
- package/dist/agent/channels/discord-invite.d.ts.map +1 -0
- package/dist/agent/channels/discord-invite.js +42 -0
- package/dist/agent/channels/discord-invite.js.map +1 -0
- package/dist/agent/channels/discord-origin-codec.d.ts.map +1 -1
- package/dist/agent/channels/discord-origin-codec.js +12 -0
- package/dist/agent/channels/discord-origin-codec.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 +62 -13
- package/dist/agent/commands/discord-commands.js.map +1 -1
- package/dist/agent/docs-generated.d.ts.map +1 -1
- package/dist/agent/docs-generated.js +506 -2
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/register.d.ts.map +1 -1
- package/dist/agent/register.js +19 -2
- package/dist/agent/register.js.map +1 -1
- package/dist/agent/surface-parity.d.ts.map +1 -1
- package/dist/agent/surface-parity.js +3 -0
- package/dist/agent/surface-parity.js.map +1 -1
- 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:
|
|
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
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
);
|
|
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
|
-
|
|
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: `
|
|
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: `
|
|
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: `
|
|
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: `
|
|
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: "
|
|
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: "
|
|
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
|
-
//
|
|
216
|
-
//
|
|
217
|
-
// (
|
|
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(
|
|
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
|
};
|