@open-everywhere/provider-discord 0.1.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/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +47 -0
- package/agent/access/discord-grant-click.ts +237 -0
- package/agent/approvals/discord-admin.ts +227 -0
- package/agent/approvals/discord-card.ts +248 -0
- package/agent/approvals/discord-flow.ts +469 -0
- package/agent/approvals/discord-scope-label.ts +27 -0
- package/agent/channels/discord-approval-card.ts +373 -0
- package/agent/channels/discord-backscroll.ts +593 -0
- package/agent/channels/discord-bot-name.ts +113 -0
- package/agent/channels/discord-channel-info.ts +83 -0
- package/agent/channels/discord-directory.ts +179 -0
- package/agent/channels/discord-dispatch-target.ts +103 -0
- package/agent/channels/discord-events.ts +175 -0
- package/agent/channels/discord-front.ts +355 -0
- package/agent/channels/discord-gateway.ts +3256 -0
- package/agent/channels/discord-identity.ts +269 -0
- package/agent/channels/discord-interaction-defer.ts +123 -0
- package/agent/channels/discord-message-ops.ts +127 -0
- package/agent/channels/discord-origin-codec.ts +105 -0
- package/agent/channels/discord-post-arm.ts +230 -0
- package/agent/channels/discord-post.ts +463 -0
- package/agent/channels/discord-read.ts +140 -0
- package/agent/channels/discord-rest.ts +85 -0
- package/agent/channels/discord.ts +244 -0
- package/agent/commands/discord-command-sync.ts +285 -0
- package/agent/commands/discord-commands.ts +291 -0
- package/agent/contributions.ts +31 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/identity/discord-link-consent.ts +124 -0
- package/agent/ops.ts +14 -0
- package/agent/provider-events.ts +86 -0
- package/agent/register.ts +147 -0
- package/agent/surface-parity.ts +95 -0
- package/dist/agent/access/discord-grant-click.d.ts +25 -0
- package/dist/agent/access/discord-grant-click.d.ts.map +1 -0
- package/dist/agent/access/discord-grant-click.js +210 -0
- package/dist/agent/access/discord-grant-click.js.map +1 -0
- package/dist/agent/approvals/discord-admin.d.ts +63 -0
- package/dist/agent/approvals/discord-admin.d.ts.map +1 -0
- package/dist/agent/approvals/discord-admin.js +219 -0
- package/dist/agent/approvals/discord-admin.js.map +1 -0
- package/dist/agent/approvals/discord-card.d.ts +102 -0
- package/dist/agent/approvals/discord-card.d.ts.map +1 -0
- package/dist/agent/approvals/discord-card.js +217 -0
- package/dist/agent/approvals/discord-card.js.map +1 -0
- package/dist/agent/approvals/discord-flow.d.ts +109 -0
- package/dist/agent/approvals/discord-flow.d.ts.map +1 -0
- package/dist/agent/approvals/discord-flow.js +375 -0
- package/dist/agent/approvals/discord-flow.js.map +1 -0
- package/dist/agent/approvals/discord-scope-label.d.ts +3 -0
- package/dist/agent/approvals/discord-scope-label.d.ts.map +1 -0
- package/dist/agent/approvals/discord-scope-label.js +27 -0
- package/dist/agent/approvals/discord-scope-label.js.map +1 -0
- package/dist/agent/channels/discord-approval-card.d.ts +81 -0
- package/dist/agent/channels/discord-approval-card.d.ts.map +1 -0
- package/dist/agent/channels/discord-approval-card.js +303 -0
- package/dist/agent/channels/discord-approval-card.js.map +1 -0
- package/dist/agent/channels/discord-backscroll.d.ts +209 -0
- package/dist/agent/channels/discord-backscroll.d.ts.map +1 -0
- package/dist/agent/channels/discord-backscroll.js +505 -0
- package/dist/agent/channels/discord-backscroll.js.map +1 -0
- package/dist/agent/channels/discord-bot-name.d.ts +19 -0
- package/dist/agent/channels/discord-bot-name.d.ts.map +1 -0
- package/dist/agent/channels/discord-bot-name.js +98 -0
- package/dist/agent/channels/discord-bot-name.js.map +1 -0
- package/dist/agent/channels/discord-channel-info.d.ts +22 -0
- package/dist/agent/channels/discord-channel-info.d.ts.map +1 -0
- package/dist/agent/channels/discord-channel-info.js +65 -0
- package/dist/agent/channels/discord-channel-info.js.map +1 -0
- package/dist/agent/channels/discord-directory.d.ts +11 -0
- package/dist/agent/channels/discord-directory.d.ts.map +1 -0
- package/dist/agent/channels/discord-directory.js +170 -0
- package/dist/agent/channels/discord-directory.js.map +1 -0
- package/dist/agent/channels/discord-dispatch-target.d.ts +15 -0
- package/dist/agent/channels/discord-dispatch-target.d.ts.map +1 -0
- package/dist/agent/channels/discord-dispatch-target.js +99 -0
- package/dist/agent/channels/discord-dispatch-target.js.map +1 -0
- package/dist/agent/channels/discord-events.d.ts +58 -0
- package/dist/agent/channels/discord-events.d.ts.map +1 -0
- package/dist/agent/channels/discord-events.js +150 -0
- package/dist/agent/channels/discord-events.js.map +1 -0
- package/dist/agent/channels/discord-front.d.ts +115 -0
- package/dist/agent/channels/discord-front.d.ts.map +1 -0
- package/dist/agent/channels/discord-front.js +279 -0
- package/dist/agent/channels/discord-front.js.map +1 -0
- package/dist/agent/channels/discord-gateway.d.ts +783 -0
- package/dist/agent/channels/discord-gateway.d.ts.map +1 -0
- package/dist/agent/channels/discord-gateway.js +2591 -0
- package/dist/agent/channels/discord-gateway.js.map +1 -0
- package/dist/agent/channels/discord-identity.d.ts +110 -0
- package/dist/agent/channels/discord-identity.d.ts.map +1 -0
- package/dist/agent/channels/discord-identity.js +201 -0
- package/dist/agent/channels/discord-identity.js.map +1 -0
- package/dist/agent/channels/discord-interaction-defer.d.ts +33 -0
- package/dist/agent/channels/discord-interaction-defer.d.ts.map +1 -0
- package/dist/agent/channels/discord-interaction-defer.js +102 -0
- package/dist/agent/channels/discord-interaction-defer.js.map +1 -0
- package/dist/agent/channels/discord-message-ops.d.ts +3 -0
- package/dist/agent/channels/discord-message-ops.d.ts.map +1 -0
- package/dist/agent/channels/discord-message-ops.js +123 -0
- package/dist/agent/channels/discord-message-ops.js.map +1 -0
- package/dist/agent/channels/discord-origin-codec.d.ts +7 -0
- package/dist/agent/channels/discord-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/discord-origin-codec.js +98 -0
- package/dist/agent/channels/discord-origin-codec.js.map +1 -0
- package/dist/agent/channels/discord-post-arm.d.ts +3 -0
- package/dist/agent/channels/discord-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/discord-post-arm.js +195 -0
- package/dist/agent/channels/discord-post-arm.js.map +1 -0
- package/dist/agent/channels/discord-post.d.ts +120 -0
- package/dist/agent/channels/discord-post.d.ts.map +1 -0
- package/dist/agent/channels/discord-post.js +426 -0
- package/dist/agent/channels/discord-post.js.map +1 -0
- package/dist/agent/channels/discord-read.d.ts +40 -0
- package/dist/agent/channels/discord-read.d.ts.map +1 -0
- package/dist/agent/channels/discord-read.js +128 -0
- package/dist/agent/channels/discord-read.js.map +1 -0
- package/dist/agent/channels/discord-rest.d.ts +33 -0
- package/dist/agent/channels/discord-rest.d.ts.map +1 -0
- package/dist/agent/channels/discord-rest.js +75 -0
- package/dist/agent/channels/discord-rest.js.map +1 -0
- package/dist/agent/channels/discord.d.ts +3 -0
- package/dist/agent/channels/discord.d.ts.map +1 -0
- package/dist/agent/channels/discord.js +230 -0
- package/dist/agent/channels/discord.js.map +1 -0
- package/dist/agent/commands/discord-command-sync.d.ts +66 -0
- package/dist/agent/commands/discord-command-sync.d.ts.map +1 -0
- package/dist/agent/commands/discord-command-sync.js +224 -0
- package/dist/agent/commands/discord-command-sync.js.map +1 -0
- package/dist/agent/commands/discord-commands.d.ts +45 -0
- package/dist/agent/commands/discord-commands.d.ts.map +1 -0
- package/dist/agent/commands/discord-commands.js +266 -0
- package/dist/agent/commands/discord-commands.js.map +1 -0
- package/dist/agent/contributions.d.ts +17 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +31 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/identity/discord-link-consent.d.ts +13 -0
- package/dist/agent/identity/discord-link-consent.d.ts.map +1 -0
- package/dist/agent/identity/discord-link-consent.js +111 -0
- package/dist/agent/identity/discord-link-consent.js.map +1 -0
- package/dist/agent/ops.d.ts +2 -0
- package/dist/agent/ops.d.ts.map +1 -0
- package/dist/agent/ops.js +13 -0
- package/dist/agent/ops.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +78 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +5 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +125 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +90 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/package.json +185 -0
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// discord-rest — THE ONE place a Discord REST WRITE call leaves this host (OPE-1236).
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS SEAM EXISTS. Every Discord write in the reply/message-ops path used to be a bare `fetch`
|
|
4
|
+
// with no 429 handling. Discord answers a rate-limited call with HTTP 429, and it states the delay
|
|
5
|
+
// TWICE: in the `Retry-After` header AND in a JSON `retry_after` body field (fractional seconds).
|
|
6
|
+
// The body field is authoritative — Discord's own docs put the precise delay there — so the shared
|
|
7
|
+
// header-only backoff would miss it. This funnel wraps every write call in the shared
|
|
8
|
+
// `withRetryAfter` primitive and feeds it a body-precedence override, so a Discord write honours the
|
|
9
|
+
// delay the vendor actually named. See `docs/extending/provider-rate-limits.md`.
|
|
10
|
+
//
|
|
11
|
+
// 🔴 WHAT THIS SEAM COVERS, EXACTLY. Every Discord WRITE in this package: the reply feature's write
|
|
12
|
+
// seam (`discord-post.ts`), the ancillary message ops (`discord-message-ops.ts`), and — since
|
|
13
|
+
// OPE-1311 — the GATEWAY's own five writes (`discord-gateway.ts`: the completion post-back, the
|
|
14
|
+
// open-DM, the typing indicator, the reaction and the thread open). Every `fetch` in those three
|
|
15
|
+
// files goes through here.
|
|
16
|
+
//
|
|
17
|
+
// It does NOT cover the four READ channels (`discord-read.ts`, `discord-backscroll.ts`,
|
|
18
|
+
// `discord-directory.ts`, `discord-bot-name.ts`), which still hold their own raw `fetch`.
|
|
19
|
+
// `discord-rest-seam-test.ts` § 3 holds that KNOWN-UNCOVERED inventory and reds when a NEW file grows
|
|
20
|
+
// its own bare `fetch(` (a write that would bypass the seam) or a covered file regrows one, so the
|
|
21
|
+
// gap cannot widen unnoticed.
|
|
22
|
+
//
|
|
23
|
+
// The gateway's writes are fail-silent, so they do not all take the shared policy: the two ephemeral
|
|
24
|
+
// affordances (typing, the reaction) pass a tighter one, because both are awaited on the turn's own
|
|
25
|
+
// latency path. `discord-gateway.ts`'s `AFFORDANCE_RETRY` carries that reasoning.
|
|
26
|
+
//
|
|
27
|
+
// The bot token is a SECRET — it rides only the caller's header, never through here, and no response
|
|
28
|
+
// body ever reaches an error string. `operation` is the id-free vendor route SHAPE (e.g.
|
|
29
|
+
// `POST /channels/{id}/messages`) that rides the NAMED `RateLimitExhaustedError`; keep ids out of it,
|
|
30
|
+
// so every field on that error stays safe to log.
|
|
31
|
+
|
|
32
|
+
import { withRetryAfter, type RetryPolicy } from "@open-everywhere/core/agent/adapter-api.js";
|
|
33
|
+
|
|
34
|
+
/** The provider surface name on the NAMED rate-limit failure. */
|
|
35
|
+
export const DISCORD_SURFACE = "discord";
|
|
36
|
+
|
|
37
|
+
/** The subset of `fetch` this seam drives — injectable so a test scripts the 429 sequence. */
|
|
38
|
+
export type DiscordFetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
39
|
+
|
|
40
|
+
/** Transport and retry seams. A test injects both; live code passes neither. */
|
|
41
|
+
export interface DiscordFetchDeps {
|
|
42
|
+
readonly fetch?: DiscordFetchLike;
|
|
43
|
+
readonly retry?: RetryPolicy;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the honoured delay from a Discord 429 body, in milliseconds, or `undefined` when the body
|
|
48
|
+
* carries no usable `retry_after` (then the seam falls back to the header). Discord states
|
|
49
|
+
* `retry_after` in SECONDS — fractional, e.g. `0.75` — so this multiplies by 1000 rather than
|
|
50
|
+
* routing through the header parser, which reads integer seconds only.
|
|
51
|
+
*
|
|
52
|
+
* 🔴 NEVER THROWS, and reads a CLONE. The primitive cancels the original body right after this
|
|
53
|
+
* returns; reading the clone leaves the original intact for that cancel. A body that is missing,
|
|
54
|
+
* not JSON, or carries no numeric `retry_after` answers `undefined` — never a wrong delay.
|
|
55
|
+
*/
|
|
56
|
+
export async function discordBodyRetryAfterMs(res: Response): Promise<number | undefined> {
|
|
57
|
+
try {
|
|
58
|
+
const body = (await res.clone().json()) as { retry_after?: unknown } | null;
|
|
59
|
+
const seconds = body?.retry_after;
|
|
60
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) return undefined;
|
|
61
|
+
return seconds * 1000;
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Issue ONE Discord REST call with the shared 429 backoff, honouring the body's `retry_after` over
|
|
69
|
+
* the header. `operation` is the id-free route shape for the named failure. `deps.retry` lets a test
|
|
70
|
+
* inject a stub sleep/clock; live callers pass nothing.
|
|
71
|
+
*
|
|
72
|
+
* Returns the FIRST non-429 response untouched — the caller reads `res.ok`/`res.status`/`res.json()`
|
|
73
|
+
* exactly as it did before this seam existed. Throws `RateLimitExhaustedError` when a 429 outlasts
|
|
74
|
+
* the bounded attempts, and re-throws a transport error unretried (an unanswered write may have
|
|
75
|
+
* applied).
|
|
76
|
+
*/
|
|
77
|
+
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);
|
|
79
|
+
return withRetryAfter(send, {
|
|
80
|
+
surface: DISCORD_SURFACE,
|
|
81
|
+
operation,
|
|
82
|
+
retryAfterMs: (res) => discordBodyRetryAfterMs(res as Response),
|
|
83
|
+
...deps.retry,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The Discord surface. Wires Discord's HTTP Interactions (slash commands) → an
|
|
2
|
+
// OpenEverywhere session, and — the point of the "Discord-binding" slice — populates the
|
|
3
|
+
// session auth context with the REAL Discord identity (guild/channel/user ids + the
|
|
4
|
+
// channel type) so the identity seam (agent/identity/binding.ts) resolves the actual
|
|
5
|
+
// workspace (the guild/server — the org boundary), surface (the channel), mode
|
|
6
|
+
// (channel/dm/mpim), and visibility (fail-closed private) instead of OE_* env
|
|
7
|
+
// stand-ins. Everything downstream — memory partition, access resolver, routine
|
|
8
|
+
// dispatch — then keys off real Discord identity with no further change.
|
|
9
|
+
//
|
|
10
|
+
// Eve's Discord channel is interaction-based, not message-based: there is no
|
|
11
|
+
// app_mention / message event, only `onCommand(ctx, interaction)` for an explicitly
|
|
12
|
+
// invoked slash command. A slash command is already a bot-directed act — you can't
|
|
13
|
+
// invoke the app without targeting it — so it is the natural analogue of Slack's
|
|
14
|
+
// mention-gating: every onCommand is, by construction, "mentioned". We accept both
|
|
15
|
+
// DMs and guild commands and still drop bot-authored invocations, then override
|
|
16
|
+
// onCommand only to attach OUR richer auth via buildDiscordAuth (the eve default
|
|
17
|
+
// derives a thinner, guild-scoped auth). Kept MINIMAL for launch — identity binding +
|
|
18
|
+
// dispatch only; no status-UX / onboarding extras (cf. slack.ts).
|
|
19
|
+
//
|
|
20
|
+
// Credentials: omitted here, so eve falls back to DISCORD_PUBLIC_KEY /
|
|
21
|
+
// DISCORD_APPLICATION_ID / DISCORD_BOT_TOKEN (see .env.example). Swap the credential
|
|
22
|
+
// broker later without touching identity. Capturing the channel type / guild presence is a
|
|
23
|
+
// RAW-fact job; the security-critical interpretation (fail-closed to private) lives in
|
|
24
|
+
// the binding (discordVisibility).
|
|
25
|
+
//
|
|
26
|
+
// FRONTED (see discord-front.ts): the wrapped eve channel sits on an internal route; the
|
|
27
|
+
// public Interactions Endpoint URL is our front, which answers `oe-approval:` approval-card
|
|
28
|
+
// button clicks (admin approvals — approvals/discord-flow.ts) and forwards everything else
|
|
29
|
+
// (PING, slash commands, eve's own HITL clicks) here unchanged. Same option-(c) pattern as
|
|
30
|
+
// the Slack channel (slack-front.ts) and for the same reason: eve exposes no onComponent hook.
|
|
31
|
+
//
|
|
32
|
+
// A THIRD, separate URL (see discord-events.ts) carries Discord's app-level WEBHOOK EVENTS —
|
|
33
|
+
// APPLICATION_AUTHORIZED / APPLICATION_DEAUTHORIZED, i.e. install/uninstall — into the audit
|
|
34
|
+
// trail. Configured on the developer portal's Webhooks page, not the Interactions one.
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
discordChannel,
|
|
38
|
+
type DiscordContext,
|
|
39
|
+
type DiscordCommandInteraction,
|
|
40
|
+
} from "eve/channels/discord";
|
|
41
|
+
import { buildDiscordAuth, threadParentFromRawChannel, discordMemberPendingFromRaw, type OpenEverywhereDiscordAuth } from "./discord-identity.js";
|
|
42
|
+
import {
|
|
43
|
+
finalMessageActionForTurn,
|
|
44
|
+
NO_REPLY_SENTINEL_REASON,
|
|
45
|
+
auditNoReplySentinelObserved,
|
|
46
|
+
isBareStop,
|
|
47
|
+
consumeRepliedThisTurn,
|
|
48
|
+
dropClosingProse,
|
|
49
|
+
resolvePartition,
|
|
50
|
+
authPrincipalId,
|
|
51
|
+
type ChatAuditPartition,
|
|
52
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
53
|
+
import { frontDiscordChannel, INTERNAL_DISCORD_ROUTE } from "./discord-front.js";
|
|
54
|
+
import { withDiscordEventsRoute } from "./discord-events.js";
|
|
55
|
+
|
|
56
|
+
// The audit partition for a terminal event, rebuilt from the LIVE session state the same
|
|
57
|
+
// way slack.ts rebuilds its own (the completion handler has no auth context): guild →
|
|
58
|
+
// workspace, channel → surface, via the REAL identity seam so rows land exactly where the
|
|
59
|
+
// dispatch partition did. Null on an unidentifiable state (no channel id) — the caller
|
|
60
|
+
// then skips the row (fail-soft; auditing must never fail or post into the turn).
|
|
61
|
+
function auditPartitionForState(state: { channelId: string | null; guildId: string | null }): ChatAuditPartition | null {
|
|
62
|
+
if (!state.channelId) return null;
|
|
63
|
+
try {
|
|
64
|
+
return resolvePartition({
|
|
65
|
+
channel: { kind: "discord" },
|
|
66
|
+
session: { auth: { current: buildDiscordAuth({ userId: "", guildId: state.guildId ?? undefined, channelId: state.channelId }) } },
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Read the Discord numeric channel `type` from the raw interaction payload. The parsed
|
|
74
|
+
// interaction exposes ids (channel/guild/user) but not the channel kind, which lives in
|
|
75
|
+
// the payload's `channel` object. Used only to tell a DM (1) / group DM (3) from a guild
|
|
76
|
+
// text channel or thread; absent → the binding falls back to guild-presence for mode and
|
|
77
|
+
// fails closed on visibility. Defensive: the payload is Discord's raw JSON.
|
|
78
|
+
function channelTypeFromRaw(raw: Record<string, unknown>): string | undefined {
|
|
79
|
+
const ch = raw["channel"];
|
|
80
|
+
if (ch && typeof ch === "object") {
|
|
81
|
+
const t = (ch as Record<string, unknown>)["type"];
|
|
82
|
+
if (typeof t === "number") return String(t);
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Attach OUR richer Discord auth: raw guild/channel/user ids + the channel type (and,
|
|
88
|
+
// for a THREAD, its parent channel id — the raw payload's channel partial carries
|
|
89
|
+
// `parent_id`, so the thread→parent domain/spend grain of item 22 is FREE here, no REST
|
|
90
|
+
// lookup like the gateway's message path needs), which the binding then interprets into
|
|
91
|
+
// workspace/surface/mode/visibility. Drops invocations by another bot — we never act on
|
|
92
|
+
// a bot's command.
|
|
93
|
+
function authFromInteraction(interaction: DiscordCommandInteraction): OpenEverywhereDiscordAuth | null {
|
|
94
|
+
if (interaction.user.isBot) return null; // bot actor — drop
|
|
95
|
+
return buildDiscordAuth({
|
|
96
|
+
userId: interaction.user.id,
|
|
97
|
+
guildId: interaction.guildId,
|
|
98
|
+
channelId: interaction.channelId,
|
|
99
|
+
channelType: channelTypeFromRaw(interaction.raw),
|
|
100
|
+
parentChannelId: threadParentFromRawChannel(interaction.raw),
|
|
101
|
+
// Guest/unverified detection (OPE-60): the interaction payload carries the guild `member`
|
|
102
|
+
// object (incl. `member.pending`) for FREE — no privileged intent, no REST call. A pending
|
|
103
|
+
// member hasn't cleared membership screening → the binding demotes trust to external.
|
|
104
|
+
memberPending: discordMemberPendingFromRaw(interaction.raw),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const discordInteractionChannel = discordChannel({
|
|
109
|
+
// Internal mount — the public URL is the approval-click front (frontDiscordChannel below).
|
|
110
|
+
route: INTERNAL_DISCORD_ROUTE,
|
|
111
|
+
// 🔴 QUEUE, NEVER STEER (OPE-412) — eve's default `steer` cancels the turn in flight when a
|
|
112
|
+
// message arrives. Same reasoning as the Slack channel; see slack.ts for the full note.
|
|
113
|
+
turnPolicy: "queue",
|
|
114
|
+
onCommand: async (ctx: DiscordContext, interaction: DiscordCommandInteraction) => {
|
|
115
|
+
const auth = authFromInteraction(interaction);
|
|
116
|
+
if (auth === null) return null; // bot author / unactionable → ack without dispatch
|
|
117
|
+
try {
|
|
118
|
+
await ctx.discord.startTyping();
|
|
119
|
+
} catch {
|
|
120
|
+
/* typing indicator is best-effort */
|
|
121
|
+
}
|
|
122
|
+
return { auth };
|
|
123
|
+
},
|
|
124
|
+
events: {
|
|
125
|
+
// Authoring this handler REPLACES eve's Discord default for "turn.started" (verified in
|
|
126
|
+
// node_modules/eve/dist/.../discord/defaults.js: `async "turn.started"(e,t,n){await
|
|
127
|
+
// t.discord.startTyping()}`), so we replicate that default body verbatim first, matching the
|
|
128
|
+
// discipline channels/slack.ts's own turn.started comment documents.
|
|
129
|
+
//
|
|
130
|
+
// OPE-645: REPLY PRINCIPAL snapshot — the same T1.4 pattern channels/slack.ts,
|
|
131
|
+
// channels/teams.ts and discord-gateway.ts each use for their own: message.completed
|
|
132
|
+
// has no live auth ctx (eve rewrites session.auth.current on every delivery, and this channel
|
|
133
|
+
// runs `turnPolicy: "queue"`, so a later queued delivery can overwrite it before this turn's
|
|
134
|
+
// own message.completed fires), but turn.started does. authPrincipalId(ctx) is the exact read
|
|
135
|
+
// bindFrom(ctx).principalId falls back to for an ordinary human turn (identity/binding.ts) —
|
|
136
|
+
// snapshotting it here means the DROP row this handler writes since OPE-2006 names the same
|
|
137
|
+
// person the turn's egress.policy rows already do. It is no longer a `reply.post` row: nothing
|
|
138
|
+
// posts here.
|
|
139
|
+
"turn.started": async (_data, channel, ctx) => {
|
|
140
|
+
// Snapshot FIRST — the attribution must not depend on the typing call succeeding (the
|
|
141
|
+
// same discipline the Slack sibling states for its supervisor target snapshot).
|
|
142
|
+
(channel.state as unknown as Record<string, unknown>).oeReplyPrincipalId = authPrincipalId(ctx);
|
|
143
|
+
await channel.discord.startTyping();
|
|
144
|
+
},
|
|
145
|
+
// Override the reply-posting handler for ONE addition: a fired `watch` routine
|
|
146
|
+
// ends its turn with the silence sentinel when the watched state is unchanged
|
|
147
|
+
// (routines/watch.ts) — swallow it, never post it. Mirrors the Slack override
|
|
148
|
+
// (channels/slack.ts) and the Teams one, via the shared, unit-tested
|
|
149
|
+
// finalMessageAction() decision. Everything else matches eve's Discord default
|
|
150
|
+
// (tool-calls and empty final messages post nothing). Only routine fires teach
|
|
151
|
+
// the sentinel on this surface; slash-command replies are unaffected.
|
|
152
|
+
"message.completed": async (data, channel, ctx) => {
|
|
153
|
+
const sessionId = ctx?.session?.id; // stamped into the terminal audit rows → joins to the transcript
|
|
154
|
+
// OPE-1647: the TURN ID, so the stand-down silence mark (channels/stand-down-silence.ts) can
|
|
155
|
+
// prove it belongs to THIS turn. Same extraction idiom this file already uses for the §POSTED
|
|
156
|
+
// consume below.
|
|
157
|
+
// 🔴 SINCE OPE-2006 IT SERVES THE SILENCE DECISION ALONE. It is read once here and handed to
|
|
158
|
+
// `finalMessageActionForTurn`, whose `consumeStandDownSilenceThisTurn(turnId)` is its only
|
|
159
|
+
// reader on this path. There is no reply audit below to stamp it onto: this surface DROPS its
|
|
160
|
+
// closing prose, so the durable latch that guarded the old post has no door left to shut here.
|
|
161
|
+
const turnId = (ctx as unknown as { session?: { turn?: { id?: string } } })?.session?.turn?.id;
|
|
162
|
+
const action = finalMessageActionForTurn(data.finishReason, data.message, turnId);
|
|
163
|
+
// An interim tool-call step, not the turn's end — don't consume the reply signal here:
|
|
164
|
+
// the tool this step is about to run may be the `reply` call itself (mirrors slack.ts).
|
|
165
|
+
if (action.kind === "buffer-tool-status") return;
|
|
166
|
+
// Terminal decision — read-and-reset the reply-tool signal once, on every terminal
|
|
167
|
+
// branch (post OR silent), so it never bleeds into the next turn (same contract as
|
|
168
|
+
// slack.ts's reactedThisTurn/repliedThisTurn consume). TURN-ID KEYED (OPE-805): only THIS
|
|
169
|
+
// turn's own mark answers true — see reply-signal.ts's header.
|
|
170
|
+
const repliedThisTurn = consumeRepliedThisTurn((ctx as unknown as { session?: { turn?: { id?: string } } })?.session?.turn?.id);
|
|
171
|
+
if (action.kind !== "post") {
|
|
172
|
+
// Only log a genuinely JUDGED silence (the NO_REPLY sentinel, or the no_reply
|
|
173
|
+
// tool) — an ordinary empty completion (nothing to say, no judgment made) isn't
|
|
174
|
+
// worth a log line.
|
|
175
|
+
if (action.kind === "silent" && action.reason !== "empty final message") {
|
|
176
|
+
console.log(`[open-everywhere:discord] judged silence (${action.reason}) — nothing posted (channel ${channel.state.channelId ?? "?"})`);
|
|
177
|
+
}
|
|
178
|
+
// ACTIONS-ONLY posture (audit-wedge build plan §1c, 2026-07-14): a judged silence is not
|
|
179
|
+
// an audited action — the transcript answers "why did it stay quiet?", not the audit log.
|
|
180
|
+
// The console line above stays; the bare-stop WARN below stays (an anomaly, not a silence).
|
|
181
|
+
//
|
|
182
|
+
// ONE exception (OPE-890): the model ended the turn on the bare NO_REPLY sentinel — the
|
|
183
|
+
// TOOL route already carries an always-on `reply.no_reply_observed` row (tools/no_reply.ts,
|
|
184
|
+
// OPE-649); this is the sentinel route's twin, same action name, best-effort. Reached only
|
|
185
|
+
// for a genuine bare-sentinel turn — finalMessageActionForTurn already resolves the tool
|
|
186
|
+
// route to its own `no_reply tool: …` reason, so a tool-called turn never lands here.
|
|
187
|
+
if (action.kind === "silent" && action.reason === NO_REPLY_SENTINEL_REASON) {
|
|
188
|
+
const p = auditPartitionForState(channel.state);
|
|
189
|
+
if (p) await auditNoReplySentinelObserved(p, sessionId).catch(() => undefined);
|
|
190
|
+
}
|
|
191
|
+
// Bare-stop enforcement (see slack-events.ts's isBareStop header for the full
|
|
192
|
+
// rationale — DETECT-AND-LOG, not a hard gate). `react` is Slack-only (no
|
|
193
|
+
// Discord equivalent wired), but the reply-tool family (reply-signal.ts) IS armed
|
|
194
|
+
// here, so a `reply`-then-silent turn must not be mis-flagged. The bare stop is
|
|
195
|
+
// ALSO covered by the discord.no_reply row above (reason "empty final message").
|
|
196
|
+
if (action.kind === "silent" && isBareStop(action, repliedThisTurn)) {
|
|
197
|
+
console.warn(
|
|
198
|
+
`[open-everywhere:discord] BARE STOP — turn ended with no reply and no no_reply (channel ${channel.state.channelId ?? "?"}) — this usually means a judgment call went missing, not that one was made.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// 🔴 THE CLOSING PROSE IS DROPPED, NOT POSTED (OPE-2006). This branch used to route the
|
|
204
|
+
// terminal reply through `postTerminalProse`, with a raw `channel.discord.post` fallback for an
|
|
205
|
+
// unidentifiable state. Both are gone: the `reply` tool is the only channel a seat's words
|
|
206
|
+
// leave by, so a turn that ended in prose and never called `reply` has said nothing here. The
|
|
207
|
+
// row is the record — no re-send, no queue, no host-side re-post.
|
|
208
|
+
// WHAT TELLS THE MODEL is the seat prompt: Discord declares `closingProseDropped`, so a
|
|
209
|
+
// conversational seat here reads the `reply-only` delivery arm, whose copy states that the
|
|
210
|
+
// turn-final text is discarded — inside its persona when the token is supervisor-shaped, and
|
|
211
|
+
// through the `delivery` slot when it is not (instructions/access.ts `renderDeliverySlot`).
|
|
212
|
+
{
|
|
213
|
+
const p = auditPartitionForState(channel.state);
|
|
214
|
+
if (p) {
|
|
215
|
+
await dropClosingProse({
|
|
216
|
+
row: {
|
|
217
|
+
org: p.org,
|
|
218
|
+
platform: p.platform,
|
|
219
|
+
workspace: p.workspace,
|
|
220
|
+
surface: p.surface,
|
|
221
|
+
principalId: (channel.state as unknown as Record<string, unknown>).oeReplyPrincipalId as string | undefined,
|
|
222
|
+
},
|
|
223
|
+
chars: action.text.length,
|
|
224
|
+
...(sessionId ? { sessionId } : {}),
|
|
225
|
+
anchor: { channelId: channel.state.channelId ?? null },
|
|
226
|
+
log: (m) => console.warn(`[open-everywhere:discord] ${m} (channel ${channel.state.channelId ?? "?"})`),
|
|
227
|
+
});
|
|
228
|
+
} else {
|
|
229
|
+
// No resolvable partition, so there is no row to file it under — the drop still happens and
|
|
230
|
+
// the log line is the whole record, the same honest degradation the silence branches above
|
|
231
|
+
// take on an unidentifiable state.
|
|
232
|
+
console.warn(
|
|
233
|
+
`[open-everywhere:discord] closing prose DROPPED (${action.text.length}c) — nothing posted, and no partition to audit it under (channel ${channel.state.channelId ?? "?"})`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// FRONTED (interactions) + the app-level WEBHOOK EVENTS route (discord-events.ts): a separate,
|
|
242
|
+
// additive URL that audits APPLICATION_AUTHORIZED / APPLICATION_DEAUTHORIZED so install/uninstall
|
|
243
|
+
// is observable. Observability only — it gates nothing and never reaches a session.
|
|
244
|
+
export default withDiscordEventsRoute(frontDiscordChannel(discordInteractionChannel));
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// THE DISCORD HALF of the surface-command reconcile plane (OPE-1687) — the deployment registers its
|
|
2
|
+
// own "/oe" command instead of waiting for an operator to run a CLI.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT DOES, ONCE PER PROCESS START: read the commands Discord holds for this application at each
|
|
5
|
+
// declared guild, project both sides onto the fields this deployment actually declares, and write
|
|
6
|
+
// ONLY when they differ. No difference ⇒ no write, so a warm deployment's steady-state cost is one
|
|
7
|
+
// vendor read per cold start.
|
|
8
|
+
//
|
|
9
|
+
// 🔴 THE WRITE IS ADDITIVE — `POST …/commands`, NEVER the bulk `PUT`. Discord's bulk PUT is a full
|
|
10
|
+
// OVERWRITE of every command at that scope, which is the right contract for the operator CLI
|
|
11
|
+
// (`scripts/register-discord-commands.ts`, run deliberately, documented as destructive) and the wrong
|
|
12
|
+
// one for an unattended boot. `docs/deploy/surfaces/discord.md` tells operators to register their own
|
|
13
|
+
// `/ask` command by hand at this same scope; a boot-time PUT of our one-command array would delete it
|
|
14
|
+
// silently on the next cold start. The per-command POST creates-or-updates by NAME and leaves every
|
|
15
|
+
// other command at the scope untouched, so this plane can only ever add or correct "/oe".
|
|
16
|
+
//
|
|
17
|
+
// 🔴 THE DIFF IS A PROJECTION, NOT A DEEP EQUAL. Discord's GET returns fields nobody declared — `id`,
|
|
18
|
+
// `application_id`, `version`, `guild_id`, `default_member_permissions`, `nsfw`, localisation maps —
|
|
19
|
+
// and omits `required` where it is false. A deep equal against the declared body would therefore
|
|
20
|
+
// differ EVERY time and turn "write only on a difference" into a write on every cold start. Both
|
|
21
|
+
// sides go through the same `projectCommand`, which keeps exactly the fields
|
|
22
|
+
// `buildOeDiscordCommandDefinition` sets and fills `required` with its Discord default.
|
|
23
|
+
//
|
|
24
|
+
// NEVER THROWS, AND NEVER BLOCKS BOOT FOR LONG. Every failure — no credential, a refused request, a
|
|
25
|
+
// timeout, malformed JSON — answers with an outcome the boot plane logs. A bot token never reaches an
|
|
26
|
+
// outcome's `detail`, and neither does a response body: a 401 says "401", not what Discord wrote back.
|
|
27
|
+
// The latency bound is two constants below, and both are load-bearing rather than tuning: no 429
|
|
28
|
+
// backoff at all, and one budget for the whole reconcile whatever the guild count.
|
|
29
|
+
//
|
|
30
|
+
// 🔴 ANY PROCESS THAT STARTS WITH BOTH CREDENTIALS WRITES TO THE DECLARED GUILD. The credential pair
|
|
31
|
+
// is the only gate, so a local `eve dev`/`eve start` run with the deployment's own `DISCORD_BOT_TOKEN`
|
|
32
|
+
// registers THAT CHECKOUT's command shape into the declared guild — see the warning in
|
|
33
|
+
// `agent/commands/discord-commands.ts`.
|
|
34
|
+
|
|
35
|
+
import type { RetryPolicy, SurfaceCommandOutcome, SurfaceCommandScope } from "@open-everywhere/core/agent/adapter-api.js";
|
|
36
|
+
import { discordFetch, type DiscordFetchLike } from "../channels/discord-rest.js";
|
|
37
|
+
import { buildOeDiscordCommandDefinition } from "./discord-commands.js";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 🔴 NO 429 BACKOFF AT ALL — the reconcile sits on the FIRST REQUEST's latency path.
|
|
41
|
+
*
|
|
42
|
+
* `withRetryAfter`'s defaults allow up to four attempts, a 30 s single wait and a 60 s total
|
|
43
|
+
* (`packages/core/agent/channels/rate-limit-retry.ts`), and its own header says a latency-critical
|
|
44
|
+
* caller must pass a tighter policy. A boot on a lambda is the strictest such caller there is: the
|
|
45
|
+
* cold start it rides is usually a Discord INTERACTION, which must be acknowledged inside 3 seconds.
|
|
46
|
+
* A 429 answered with `retry_after: 60` would otherwise sleep 30 s inside the boot's own top-level
|
|
47
|
+
* await, blow that ack, and possibly time the invocation out. So a rate-limited Discord is a plain
|
|
48
|
+
* failure here — the next cold start reads again, and the command list is stale meanwhile, which is
|
|
49
|
+
* the state it was already in.
|
|
50
|
+
*
|
|
51
|
+
* The same reasoning is why `discord-gateway.ts` gives its two ephemeral affordances `AFFORDANCE_RETRY`.
|
|
52
|
+
*/
|
|
53
|
+
const BOOT_RETRY: RetryPolicy = { maxAttempts: 1 };
|
|
54
|
+
|
|
55
|
+
/** How long ONE Discord call may take. `AbortSignal.timeout` bounds a call, never a backoff sleep —
|
|
56
|
+
* that is what {@link BOOT_RETRY} removes. */
|
|
57
|
+
const CALL_TIMEOUT_MS = 1_500;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The ceiling on the WHOLE reconcile, across every declared guild and both of each guild's calls.
|
|
61
|
+
* Without it the worst case multiplies by declared guild, and "bounded" would depend on how many
|
|
62
|
+
* rooms an operator listed. A guild the budget cannot reach reports `failed` and is read again on the
|
|
63
|
+
* next cold start.
|
|
64
|
+
*/
|
|
65
|
+
const RECONCILE_BUDGET_MS = 2_000;
|
|
66
|
+
|
|
67
|
+
const API_BASE = "https://discord.com/api/v10";
|
|
68
|
+
|
|
69
|
+
/** The projected shape both sides are compared in — exactly the fields this deployment declares. */
|
|
70
|
+
export interface ProjectedOption {
|
|
71
|
+
readonly name: string;
|
|
72
|
+
readonly description: string;
|
|
73
|
+
readonly type: number;
|
|
74
|
+
readonly required: boolean;
|
|
75
|
+
readonly min_value?: number;
|
|
76
|
+
readonly max_value?: number;
|
|
77
|
+
readonly options: readonly ProjectedOption[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ProjectedCommand {
|
|
81
|
+
readonly name: string;
|
|
82
|
+
readonly type: number;
|
|
83
|
+
readonly description: string;
|
|
84
|
+
readonly options: readonly ProjectedOption[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
88
|
+
return typeof v === "object" && v !== null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function str(v: unknown): string {
|
|
92
|
+
return typeof v === "string" ? v : "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function numOrUndefined(v: unknown): number | undefined {
|
|
96
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function projectOptions(raw: unknown): readonly ProjectedOption[] {
|
|
100
|
+
if (!Array.isArray(raw)) return [];
|
|
101
|
+
const out: ProjectedOption[] = [];
|
|
102
|
+
for (const entry of raw) {
|
|
103
|
+
if (!isObject(entry)) continue;
|
|
104
|
+
const min = numOrUndefined(entry["min_value"]);
|
|
105
|
+
const max = numOrUndefined(entry["max_value"]);
|
|
106
|
+
out.push({
|
|
107
|
+
name: str(entry["name"]),
|
|
108
|
+
description: str(entry["description"]),
|
|
109
|
+
type: numOrUndefined(entry["type"]) ?? 0,
|
|
110
|
+
// Discord OMITS `required` when it is false, so the default is part of the projection. Without
|
|
111
|
+
// it every optional option reads as a difference and the reconcile writes on every cold start.
|
|
112
|
+
required: entry["required"] === true,
|
|
113
|
+
...(min === undefined ? {} : { min_value: min }),
|
|
114
|
+
...(max === undefined ? {} : { max_value: max }),
|
|
115
|
+
options: projectOptions(entry["options"]),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Project one ApplicationCommand — declared or vendor-held — onto the fields this deployment sets.
|
|
123
|
+
* Returns undefined for anything that is not an object with a name, which is what a malformed or
|
|
124
|
+
* unexpected list entry looks like.
|
|
125
|
+
*
|
|
126
|
+
* OPTION ORDER IS PART OF THE PROJECTION. Discord renders a command's options in the order they were
|
|
127
|
+
* registered, so a reordered list is a real user-visible difference and must be written. Discord
|
|
128
|
+
* returns them in the registered order, so a steady state never reorders on its own.
|
|
129
|
+
*/
|
|
130
|
+
export function projectCommand(raw: unknown): ProjectedCommand | undefined {
|
|
131
|
+
if (!isObject(raw)) return undefined;
|
|
132
|
+
const name = str(raw["name"]);
|
|
133
|
+
if (name.length === 0) return undefined;
|
|
134
|
+
return {
|
|
135
|
+
name,
|
|
136
|
+
// Discord's own default for `type` is 1 (CHAT_INPUT); the declaration sets it explicitly.
|
|
137
|
+
type: numOrUndefined(raw["type"]) ?? 1,
|
|
138
|
+
description: str(raw["description"]),
|
|
139
|
+
options: projectOptions(raw["options"]),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A stable string for one projected command, so two projections compare as text. */
|
|
144
|
+
function canonical(command: ProjectedCommand): string {
|
|
145
|
+
const opt = (o: ProjectedOption): unknown => [o.name, o.description, o.type, o.required, o.min_value ?? null, o.max_value ?? null, o.options.map(opt)];
|
|
146
|
+
return JSON.stringify([command.name, command.type, command.description, command.options.map(opt)]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface DiscordCommandDrift {
|
|
150
|
+
/** Does Discord need a write for this command? */
|
|
151
|
+
readonly changed: boolean;
|
|
152
|
+
/** Why — one id-free line for the boot log. */
|
|
153
|
+
readonly reason: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Compare the command this deployment DECLARES against the list Discord HOLDS at one scope.
|
|
158
|
+
*
|
|
159
|
+
* `held` is the raw array Discord's GET returned. Only the entry whose name matches the declared
|
|
160
|
+
* command is considered: every other command at that scope belongs to someone else and this plane
|
|
161
|
+
* neither reads nor writes it.
|
|
162
|
+
*/
|
|
163
|
+
export function discordCommandDrift(declared: unknown, held: unknown): DiscordCommandDrift {
|
|
164
|
+
const want = projectCommand(declared);
|
|
165
|
+
if (want === undefined) {
|
|
166
|
+
// A declaration this file cannot project is a code defect, not a vendor state. Refusing to write
|
|
167
|
+
// is the safe half: a bad body would replace a working command with a broken one.
|
|
168
|
+
return { changed: false, reason: "the declared command has no name — nothing was compared and nothing will be written" };
|
|
169
|
+
}
|
|
170
|
+
const list = Array.isArray(held) ? held : [];
|
|
171
|
+
const mine = list.map((entry) => projectCommand(entry)).find((entry) => entry !== undefined && entry.name === want.name);
|
|
172
|
+
if (mine === undefined) {
|
|
173
|
+
return { changed: true, reason: `"/${want.name}" is not registered at this scope` };
|
|
174
|
+
}
|
|
175
|
+
if (canonical(mine) !== canonical(want)) {
|
|
176
|
+
return { changed: true, reason: `"/${want.name}" is registered with a different shape` };
|
|
177
|
+
}
|
|
178
|
+
return { changed: false, reason: `"/${want.name}" already matches what this deployment declares` };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface DiscordCommandSyncDeps {
|
|
182
|
+
readonly fetch?: DiscordFetchLike;
|
|
183
|
+
/** Overrides the env reads. Live boot passes neither; a test passes both. */
|
|
184
|
+
readonly applicationId?: string;
|
|
185
|
+
readonly botToken?: string;
|
|
186
|
+
/** The ceiling on ONE call. Defaults to {@link CALL_TIMEOUT_MS}. */
|
|
187
|
+
readonly timeoutMs?: number;
|
|
188
|
+
/** The ceiling on the WHOLE reconcile. Defaults to {@link RECONCILE_BUDGET_MS}. */
|
|
189
|
+
readonly budgetMs?: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface ScopeResult {
|
|
193
|
+
readonly status: "unchanged" | "updated" | "failed";
|
|
194
|
+
readonly detail: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Read + compare + (only on a difference) write, for ONE guild. Never throws. `msLeft` is what is
|
|
198
|
+
* left of the whole reconcile's budget, so no single guild can spend more than the rest of them. */
|
|
199
|
+
async function reconcileOneGuild(
|
|
200
|
+
guildId: string,
|
|
201
|
+
applicationId: string,
|
|
202
|
+
botToken: string,
|
|
203
|
+
deps: DiscordCommandSyncDeps,
|
|
204
|
+
msLeft: () => number,
|
|
205
|
+
): Promise<ScopeResult> {
|
|
206
|
+
const declared = buildOeDiscordCommandDefinition();
|
|
207
|
+
const url = `${API_BASE}/applications/${applicationId}/guilds/${guildId}/commands`;
|
|
208
|
+
const headers = { authorization: `Bot ${botToken}`, "content-type": "application/json" };
|
|
209
|
+
const cap = deps.timeoutMs ?? CALL_TIMEOUT_MS;
|
|
210
|
+
const fetchDeps = { ...(deps.fetch ? { fetch: deps.fetch } : {}), retry: BOOT_RETRY };
|
|
211
|
+
/** This call's own ceiling: the per-call cap, or whatever is left of the reconcile's budget. */
|
|
212
|
+
const nextTimeout = (): number => Math.min(cap, msLeft());
|
|
213
|
+
|
|
214
|
+
if (nextTimeout() <= 0) {
|
|
215
|
+
return { status: "failed", detail: `guild ${guildId}: the boot budget was already spent on an earlier guild — read again on the next start` };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let held: unknown;
|
|
219
|
+
try {
|
|
220
|
+
const res = await discordFetch("GET /applications/{id}/guilds/{id}/commands", url, { method: "GET", headers, signal: AbortSignal.timeout(nextTimeout()) }, fetchDeps);
|
|
221
|
+
if (!res.ok) return { status: "failed", detail: `guild ${guildId}: the command list read returned ${res.status}` };
|
|
222
|
+
held = await res.json();
|
|
223
|
+
} catch (err) {
|
|
224
|
+
return { status: "failed", detail: `guild ${guildId}: the command list read failed (${err instanceof Error ? err.name : "error"})` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const drift = discordCommandDrift(declared, held);
|
|
228
|
+
if (!drift.changed) return { status: "unchanged", detail: `guild ${guildId}: ${drift.reason}` };
|
|
229
|
+
|
|
230
|
+
if (nextTimeout() <= 0) {
|
|
231
|
+
return { status: "failed", detail: `guild ${guildId}: ${drift.reason}, and the boot budget was spent before the write — retried on the next start` };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// CREATE-OR-UPDATE BY NAME. Discord treats a POST naming an existing command as an update of
|
|
236
|
+
// that command, and touches nothing else at the scope — see the header on why the bulk PUT is
|
|
237
|
+
// wrong here.
|
|
238
|
+
const res = await discordFetch(
|
|
239
|
+
"POST /applications/{id}/guilds/{id}/commands",
|
|
240
|
+
url,
|
|
241
|
+
{ method: "POST", headers, body: JSON.stringify(declared), signal: AbortSignal.timeout(nextTimeout()) },
|
|
242
|
+
fetchDeps,
|
|
243
|
+
);
|
|
244
|
+
if (!res.ok) return { status: "failed", detail: `guild ${guildId}: ${drift.reason}, and the write returned ${res.status}` };
|
|
245
|
+
return { status: "updated", detail: `guild ${guildId}: ${drift.reason} — registered` };
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return { status: "failed", detail: `guild ${guildId}: ${drift.reason}, and the write failed (${err instanceof Error ? err.name : "error"})` };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* THE RECONCILER the discord bundle registers with core's command plane.
|
|
253
|
+
*
|
|
254
|
+
* Runs at boot, so it is gated on BOTH deployment credentials being present: `DISCORD_APPLICATION_ID`
|
|
255
|
+
* says which app this is and `DISCORD_BOT_TOKEN` authenticates the call. Either missing ⇒ `skipped`,
|
|
256
|
+
* which is the correct reading of a deployment that carries the bundle but no Discord app — and it is
|
|
257
|
+
* also what keeps every offline test tier network-free, since no test publishes an application id.
|
|
258
|
+
*
|
|
259
|
+
* 🔴 THESE TWO STAY ENV. They are the credential pair this deployment speaks as, which is exactly the
|
|
260
|
+
* `secret`/`identity` half of the config-as-code line. The GUILD ids do not: they are tracked literals
|
|
261
|
+
* in `agent/commands/discord-commands.ts` and arrive here as the declared scope.
|
|
262
|
+
*/
|
|
263
|
+
export async function reconcileDiscordCommands(scope: SurfaceCommandScope, deps: DiscordCommandSyncDeps = {}): Promise<SurfaceCommandOutcome> {
|
|
264
|
+
const applicationId = deps.applicationId ?? process.env.DISCORD_APPLICATION_ID;
|
|
265
|
+
const botToken = deps.botToken ?? process.env.DISCORD_BOT_TOKEN;
|
|
266
|
+
if (!applicationId || !botToken) {
|
|
267
|
+
return {
|
|
268
|
+
status: "skipped",
|
|
269
|
+
detail: "DISCORD_APPLICATION_ID and DISCORD_BOT_TOKEN must both be set for this deployment to register its own commands.",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const deadline = Date.now() + (deps.budgetMs ?? RECONCILE_BUDGET_MS);
|
|
274
|
+
const msLeft = (): number => deadline - Date.now();
|
|
275
|
+
const results: ScopeResult[] = [];
|
|
276
|
+
for (const guildId of scope.scopes) {
|
|
277
|
+
results.push(await reconcileOneGuild(guildId, applicationId, botToken, deps, msLeft));
|
|
278
|
+
}
|
|
279
|
+
const detail = results.map((r) => r.detail).join("; ");
|
|
280
|
+
// A failure on ANY guild is the outcome, even when another guild succeeded: the boot log must not
|
|
281
|
+
// read green while one room is still missing the command.
|
|
282
|
+
if (results.some((r) => r.status === "failed")) return { status: "failed", detail };
|
|
283
|
+
if (results.some((r) => r.status === "updated")) return { status: "updated", detail };
|
|
284
|
+
return { status: "unchanged", detail };
|
|
285
|
+
}
|