@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,269 @@
|
|
|
1
|
+
// Pure Discord→OpenEverywhere identity capture. The Discord channel (discord.ts) calls these
|
|
2
|
+
// to stow RAW Discord facts (guild/channel/user ids + the channel `type`) into the
|
|
3
|
+
// session auth context's `attributes`. The INTERPRETATION of those facts into
|
|
4
|
+
// mode/visibility lives here too (discordMode/discordVisibility) — the one tested place
|
|
5
|
+
// for the security-critical "is this private?" decision, mirroring Slack's split (the
|
|
6
|
+
// Slack interpretation lives in agent/identity/binding.ts; per the build brief the
|
|
7
|
+
// Discord interpretation is co-located here so the test can call it directly, and the
|
|
8
|
+
// binding mapper imports it). This file imports no eve types — only OpenEverywhere's own
|
|
9
|
+
// SessionMode / Visibility — so it stays trivially unit-testable.
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
SessionMode,
|
|
13
|
+
Visibility,
|
|
14
|
+
Trust,
|
|
15
|
+
BindableContext,
|
|
16
|
+
IdentityMapper,
|
|
17
|
+
RawIdentity,
|
|
18
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
19
|
+
import { rootDmGrainAttrs } from "@open-everywhere/core/agent/adapter-api.js";
|
|
20
|
+
|
|
21
|
+
/** Structural match for eve's SessionAuthContext (attributes + principal). */
|
|
22
|
+
export interface OpenEverywhereDiscordAuth {
|
|
23
|
+
readonly authenticator: string;
|
|
24
|
+
readonly principalId: string;
|
|
25
|
+
readonly principalType: string;
|
|
26
|
+
readonly attributes: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Discord channel `type` integers we distinguish (as strings, since auth attributes
|
|
30
|
+
// are strings). https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
|
31
|
+
// 1 = DM, 3 = GROUP_DM; everything else with a guild id is a guild text channel /
|
|
32
|
+
// thread (0 GUILD_TEXT, 5 GUILD_ANNOUNCEMENT, 10/11/12 threads, 15 FORUM, …).
|
|
33
|
+
const DISCORD_CHANNEL_DM = "1";
|
|
34
|
+
const DISCORD_CHANNEL_GROUP_DM = "3";
|
|
35
|
+
|
|
36
|
+
export interface DiscordIdentityInput {
|
|
37
|
+
readonly userId: string;
|
|
38
|
+
/** The guild (server) id — present iff invoked in a server; absent for DMs. */
|
|
39
|
+
readonly guildId?: string;
|
|
40
|
+
readonly channelId: string;
|
|
41
|
+
/** Discord numeric channel `type` as a string ("1" DM, "3" group DM, else guild). */
|
|
42
|
+
readonly channelType?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Positively-confirmed @everyone VIEW_CHANNEL — only set when a (future) enrichment
|
|
45
|
+
* proves the guild channel is readable by everyone. Absent today (Discord interactions
|
|
46
|
+
* carry no such bit), so the binding fails closed to private. See discordVisibility.
|
|
47
|
+
*/
|
|
48
|
+
readonly everyoneCanView?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The PARENT channel id when `channelId` is a THREAD (Discord threads are their own
|
|
51
|
+
* channel ids, so without this a thread's surface key can never match a domain that
|
|
52
|
+
* lists its parent channel, and channel-scoped spend caps never see thread turns —
|
|
53
|
+
* backlog item 22). Set at ingress where it's known for free (the auto-thread re-key:
|
|
54
|
+
* the parent IS the base channel the mention landed in) or via the cached REST lookup
|
|
55
|
+
* (channels/discord-channel-info.ts) for messages arriving in an existing thread.
|
|
56
|
+
* BEST-EFFORT: absent → the binding falls back to thread grain (today's behaviour) —
|
|
57
|
+
* this only ever WIDENS a thread to its parent's domain/cap scope, never gates a turn.
|
|
58
|
+
*/
|
|
59
|
+
readonly parentChannelId?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Whether the acting guild member is PENDING — has not yet completed the guild's membership
|
|
62
|
+
* screening (Community-server rules / verification gate). Discord carries `member.pending`
|
|
63
|
+
* on the interaction payload (and on gateway member objects) for FREE — no privileged intent
|
|
64
|
+
* and no REST call. A pending member is not a fully-admitted participant, so it demotes the
|
|
65
|
+
* surface's trust to `external` (the closest Discord analog of a "guest / unverified" account).
|
|
66
|
+
* `undefined` (the common case — most guilds run no screening, so the field is simply absent,
|
|
67
|
+
* or a DM has no guild member) → no verdict, existing fail-closed/vouch behaviour. See
|
|
68
|
+
* discordTrust for exactly what this signal does and does NOT prove.
|
|
69
|
+
*/
|
|
70
|
+
readonly memberPending?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The id of the message that triggered this turn, when the ingress knows one (OPE-671). A
|
|
73
|
+
* slash-command interaction carries none (Discord interactions are not message events), so this
|
|
74
|
+
* is set only by the gateway's message-relay path, where the raw message id is known for free.
|
|
75
|
+
* See {@link RawIdentity.anchorMessageId} for why this rides a SEPARATE field from `threadTs`.
|
|
76
|
+
*/
|
|
77
|
+
readonly messageId?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build the auth context OpenEverywhere dispatches with — raw facts as string attributes. */
|
|
81
|
+
export function buildDiscordAuth(input: DiscordIdentityInput): OpenEverywhereDiscordAuth {
|
|
82
|
+
const attributes: Record<string, string> = {
|
|
83
|
+
channel_id: input.channelId,
|
|
84
|
+
user_id: input.userId,
|
|
85
|
+
};
|
|
86
|
+
if (input.guildId) attributes.guild_id = input.guildId;
|
|
87
|
+
if (input.channelType) attributes.channel_type = input.channelType;
|
|
88
|
+
if (typeof input.everyoneCanView === "boolean") attributes.everyone_can_view = String(input.everyoneCanView);
|
|
89
|
+
if (input.parentChannelId) attributes.parent_channel_id = input.parentChannelId;
|
|
90
|
+
if (typeof input.memberPending === "boolean") attributes.member_pending = String(input.memberPending);
|
|
91
|
+
if (input.messageId) attributes.message_id = input.messageId;
|
|
92
|
+
// Root-DM grain stamp (window-tool-gating §D-DM, widened to group DMs): a 1:1 DM (type 1, or no
|
|
93
|
+
// guild) OR a group DM (type 3 ⇒ "mpim") is a STANDING seat ⇒ narrowed hands — both are closed
|
|
94
|
+
// multi-party conversations with no channel identity. discordMode's "channel" branch (a positive
|
|
95
|
+
// guild id) is unambiguously excluded, so a real guild channel/thread is never over-narrowed.
|
|
96
|
+
const dmMode = discordMode(input.channelType, input.guildId);
|
|
97
|
+
Object.assign(attributes, rootDmGrainAttrs(dmMode === "dm" || dmMode === "mpim"));
|
|
98
|
+
return { authenticator: "discord", principalId: input.userId, principalType: "discord-user", attributes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The PARENT channel id when a raw payload's `channel` partial is a THREAD (types
|
|
103
|
+
* 10/11/12) — Discord stamps `parent_id` on thread channel objects, so the interaction
|
|
104
|
+
* path gets the item-22 thread→parent grain for free (the gateway's message path has no
|
|
105
|
+
* channel partial and pays a cached REST lookup instead — discord-channel-info.ts).
|
|
106
|
+
* Non-threads return undefined: a base channel's `parent_id` is its CATEGORY, never a
|
|
107
|
+
* grain anchor. Defensive — the payload is Discord's raw JSON. Pure.
|
|
108
|
+
*/
|
|
109
|
+
export function threadParentFromRawChannel(raw: Record<string, unknown>): string | undefined {
|
|
110
|
+
const ch = raw["channel"];
|
|
111
|
+
if (ch && typeof ch === "object") {
|
|
112
|
+
const c = ch as Record<string, unknown>;
|
|
113
|
+
const isThread = c["type"] === 10 || c["type"] === 11 || c["type"] === 12;
|
|
114
|
+
const parent = c["parent_id"];
|
|
115
|
+
if (isThread && typeof parent === "string" && parent.length > 0) return parent;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Discord channel type / guild presence → session mode (channel | dm | mpim).
|
|
122
|
+
* A guild id positively marks a server channel/thread; a DM (1) and group DM (3) are
|
|
123
|
+
* the only non-channel surfaces. With no type captured, the absence of a guild id
|
|
124
|
+
* reliably marks a DM with the bot (interactions outside a guild are DMs).
|
|
125
|
+
*/
|
|
126
|
+
export function discordMode(channelType: string | undefined, guildId: string | undefined): SessionMode {
|
|
127
|
+
if (channelType === DISCORD_CHANNEL_DM) return "dm";
|
|
128
|
+
if (channelType === DISCORD_CHANNEL_GROUP_DM) return "mpim";
|
|
129
|
+
if (guildId && guildId.length > 0) return "channel";
|
|
130
|
+
return "dm"; // no guild → a DM surface
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Discord facts → visibility. FAIL CLOSED. Unlike Slack (an explicit is_private bit
|
|
135
|
+
* on the channel), Discord exposes NO clean public/private signal on an inbound
|
|
136
|
+
* interaction: guild-channel readability is governed by per-role permission overwrites
|
|
137
|
+
* (@everyone's VIEW_CHANNEL), which the interaction payload doesn't carry. So:
|
|
138
|
+
* - DMs / group-DMs are inherently private surfaces (never public).
|
|
139
|
+
* - Guild text channels / threads default to `private` too — conservative, so a
|
|
140
|
+
* surface's learnings stay surface-tier and never leak up to shared (workspace)
|
|
141
|
+
* memory on an under-specified event.
|
|
142
|
+
* Only a positively-confirmed @everyone-readable channel (everyoneCanView="true", from
|
|
143
|
+
* a future enrichment that probes the @everyone role's permissions) yields `public`.
|
|
144
|
+
* When in doubt, private.
|
|
145
|
+
*/
|
|
146
|
+
export function discordVisibility(everyoneCanView: string | undefined, mode: SessionMode): Visibility {
|
|
147
|
+
if (mode === "dm" || mode === "mpim") return "private"; // DMs / group-DMs are never public
|
|
148
|
+
if (everyoneCanView === "true") return "public"; // explicit @everyone-readable guild channel
|
|
149
|
+
return "private"; // unknown @everyone readability -> fail closed
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The `member.pending` flag from a raw Discord interaction / gateway payload — Discord's
|
|
154
|
+
* membership-screening bit, stamped on the guild `member` object when the server runs a rules /
|
|
155
|
+
* verification gate. Pure + defensive (raw JSON). `true`/`false` are returned as-is; anything
|
|
156
|
+
* else (no `member`, a DM, an older payload) → `undefined`. Free at ingress: no privileged
|
|
157
|
+
* intent, no REST lookup.
|
|
158
|
+
*/
|
|
159
|
+
export function discordMemberPendingFromRaw(raw: Record<string, unknown>): boolean | undefined {
|
|
160
|
+
const m = raw["member"];
|
|
161
|
+
if (m && typeof m === "object") {
|
|
162
|
+
const p = (m as Record<string, unknown>)["pending"];
|
|
163
|
+
if (typeof p === "boolean") return p;
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Discord member → trust (memory gate). Discord has NO org-employee concept — a guild is the
|
|
170
|
+
* only boundary, and guilds are routinely PUBLIC community servers, so being a guild member is
|
|
171
|
+
* NOT proof of internal trust (that always requires an operator vouch / OE_TRUST). The one
|
|
172
|
+
* POSITIVE demotion signal Discord hands us for free is `member.pending`: a member who has not
|
|
173
|
+
* completed the guild's membership screening is unverified, so a pending member DEMOTES to
|
|
174
|
+
* `external` — the Discord analog of a Slack guest / stranger, and it overrides an operator
|
|
175
|
+
* vouch exactly as Slack's restricted-DM-partner verdict does (bindFrom precedence).
|
|
176
|
+
*
|
|
177
|
+
* What it PROVES (pending=true): the user has not cleared the guild's rules/verification gate →
|
|
178
|
+
* demote. What it does NOT prove (pending=false / absent): internal trust — most guilds run no
|
|
179
|
+
* screening at all, so the field is usually absent, and even a fully-admitted member of a public
|
|
180
|
+
* guild is a stranger. So a non-pending member yields `undefined` (no verdict): the surface
|
|
181
|
+
* stays fail-closed to `external` unless the operator vouches — mirroring Slack, where channel
|
|
182
|
+
* membership alone never grants internal trust.
|
|
183
|
+
*/
|
|
184
|
+
export function discordTrust(memberPending: string | undefined): Trust | undefined {
|
|
185
|
+
if (memberPending === "true") return "external"; // membership screening incomplete → demote (wins over vouch)
|
|
186
|
+
return undefined; // non-pending / unknown → no verdict → existing fail-closed/vouch chain
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- the identity MAPPER (moved out of core identity/binding.ts in OPE-114 stage 4) ---------
|
|
190
|
+
// Interprets the raw facts the Discord channel stowed in auth attributes (guild_id, channel_id,
|
|
191
|
+
// channel_type, user_id, everyone_can_view, parent_channel_id, member_pending). workspace = the
|
|
192
|
+
// GUILD (server) — the org boundary; a DM (no guild) keys to the caller's own user id so the DM
|
|
193
|
+
// surface stays isolated. mode/visibility/trust interpretation lives in the helpers above (the one
|
|
194
|
+
// tested place). Registered by registerDiscordProvider() via the registerIdentityMapper seam.
|
|
195
|
+
//
|
|
196
|
+
// The three attribute helpers are LOCAL COPIES of core binding.ts's module-private probes (attr /
|
|
197
|
+
// authOf / authPrincipalId) — deliberately not barrel ports (they are three-line structural reads
|
|
198
|
+
// over the public BindableContext shape; a mapper author outside core reads its own platform's
|
|
199
|
+
// attributes itself, exactly as bindNotion does in @open-everywhere/provider-notion).
|
|
200
|
+
|
|
201
|
+
type AuthAttrs = Readonly<Record<string, string | readonly string[]>> | undefined;
|
|
202
|
+
|
|
203
|
+
function discordAuthOf(ctx: BindableContext): { attributes?: AuthAttrs; principalId?: string } | null {
|
|
204
|
+
return ctx.session?.auth?.current ?? ctx.session?.auth?.initiator ?? null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function discordAttr(a: AuthAttrs, keys: readonly string[]): string | undefined {
|
|
208
|
+
if (!a) return undefined;
|
|
209
|
+
for (const k of keys) {
|
|
210
|
+
const v = a[k];
|
|
211
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
212
|
+
if (Array.isArray(v) && typeof v[0] === "string" && v[0].length > 0) return v[0];
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function discordPrincipalId(ctx: BindableContext): string | undefined {
|
|
218
|
+
const p = discordAuthOf(ctx)?.principalId;
|
|
219
|
+
return p && p.length > 0 ? p : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const bindDiscord: IdentityMapper = (ctx): RawIdentity => {
|
|
223
|
+
const a = discordAuthOf(ctx)?.attributes;
|
|
224
|
+
const channelId = discordAttr(a, ["channel_id", "channelId"]);
|
|
225
|
+
const guildId = discordAttr(a, ["guild_id", "guildId"]);
|
|
226
|
+
const channelType = discordAttr(a, ["channel_type", "channelType"]);
|
|
227
|
+
const everyoneCanView = discordAttr(a, ["everyone_can_view", "everyoneCanView"]);
|
|
228
|
+
const userId = discordAttr(a, ["user_id", "userId"]);
|
|
229
|
+
const messageId = discordAttr(a, ["message_id", "messageId"]);
|
|
230
|
+
const mode = discordMode(channelType, guildId);
|
|
231
|
+
return {
|
|
232
|
+
// Guild = the org boundary; a DM (no guild) keys to the caller's user id.
|
|
233
|
+
workspace: guildId ?? userId,
|
|
234
|
+
surface: channelId,
|
|
235
|
+
// A THREAD turn carries its parent channel id (stamped at ingress), so domain membership /
|
|
236
|
+
// spend caps resolve at the PARENT channel grain, matching Slack. Absent → thread grain.
|
|
237
|
+
domainSurface: discordAttr(a, ["parent_channel_id", "parentChannelId"]),
|
|
238
|
+
mode,
|
|
239
|
+
visibility: discordVisibility(everyoneCanView, mode),
|
|
240
|
+
// Trust: guild membership alone is NEVER internal (public servers are strangers); the one
|
|
241
|
+
// positive free signal is member.pending (OPE-60) → discordTrust demotes it to external.
|
|
242
|
+
trust: discordTrust(discordAttr(a, ["member_pending", "memberPending"])),
|
|
243
|
+
principalId: discordPrincipalId(ctx) ?? userId,
|
|
244
|
+
// THE ENGAGEMENT-REGISTRY KEY (OPE-661). Discord has no thread ts: a thread is its own channel,
|
|
245
|
+
// so the gateway keys the registry by the channel id — `engagementKey(msg)` returns
|
|
246
|
+
// `msg.channelId`, and the wake gate reads back `getThreadEngagement(partition, msg.channelId)`.
|
|
247
|
+
// The same id is this binding's `surface`, so `discord/<guild>/<channelId>` + `<channelId>`
|
|
248
|
+
// addresses the exact row the gate consults. Publishing it here is what lets `stand_down` mark
|
|
249
|
+
// that row; without it the tool found no anchor and threw on every Discord turn.
|
|
250
|
+
//
|
|
251
|
+
// GUILD SURFACES ONLY, and the omission is the honest half. The gate short-circuits a DM before
|
|
252
|
+
// the engagement read (`isDm || mentionsBot ? null : …`), so a mark on a DM key would suppress
|
|
253
|
+
// nothing. A DM therefore carries NO anchor, and `stand_down` says it could not stop rather than
|
|
254
|
+
// writing a row that changes nothing.
|
|
255
|
+
...(mode === "channel" && channelId ? { engagementKey: channelId } : {}),
|
|
256
|
+
// THE STOP-HAND ANCHOR (OPE-671). `threadTs` stays Slack-shaped (see its own field doc on
|
|
257
|
+
// SurfaceBinding — ~twenty consumers read it as a Slack thread_ts API parameter), so a Discord
|
|
258
|
+
// message id rides its OWN field instead, exactly the OPE-661 precedent for `engagementKey`.
|
|
259
|
+
// Set whenever the ingress knows the triggering message (the gateway's relay path); absent on a
|
|
260
|
+
// slash-command interaction (no message) and on any fire with no live trigger — both already
|
|
261
|
+
// degrade safely, since hooks/turn-anchor.ts's ✋ placement is fail-soft on a missing anchor.
|
|
262
|
+
// UNLIKE `engagementKey`, not restricted to guild channels — a Discord DM turn has a real
|
|
263
|
+
// triggering message too, and a DM delegation is just as stoppable as a guild one.
|
|
264
|
+
...(messageId ? { anchorMessageId: messageId } : {}),
|
|
265
|
+
// Post-back needs the channel id; the guild id + channel type ride along as RAW FACTS so a
|
|
266
|
+
// routine fire (no live interaction) can re-derive workspace (guild) and mode (dm vs channel).
|
|
267
|
+
target: channelId ? { channelId, ...(guildId ? { guildId } : {}), ...(channelType ? { channelType } : {}) } : undefined,
|
|
268
|
+
};
|
|
269
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// THE DEFERRED INTERACTION RESPONSE (OPE-1282) — how a Discord door answers work that outlasts the
|
|
2
|
+
// three-second interaction token.
|
|
3
|
+
//
|
|
4
|
+
// WHAT WAS WRONG. Discord gives an interactions endpoint THREE SECONDS to answer, and it does NOT
|
|
5
|
+
// redeliver an expired token. A door that decides first and answers second — `await the work, then
|
|
6
|
+
// return the reply` — therefore has a window in which the work really lands and the person sees a
|
|
7
|
+
// red "This interaction failed" over it. `access/discord-grant-click.ts` is that shape: an APPROVE
|
|
8
|
+
// spends a 2000 ms borrow-read budget, then a CAS, a grant insert, audit rows and an awaited post
|
|
9
|
+
// into the requesting room. When it overruns, the owner believes their approval failed while a
|
|
10
|
+
// conversation can already spend their credential, and a second tap answers "that request is no
|
|
11
|
+
// longer open" — true, and reading like a second failure. Slack does not have this: it redelivers an
|
|
12
|
+
// interactivity payload it has not seen answered. Discord does not.
|
|
13
|
+
//
|
|
14
|
+
// THE SHAPE THAT CLOSES IT — ack, then decide, then edit:
|
|
15
|
+
// 1. Answer the interaction IMMEDIATELY with {@link deferredEphemeralResponse} (`type 5`,
|
|
16
|
+
// DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, `flags 64`). Discord renders "thinking…", privately,
|
|
17
|
+
// and the token is now good for fifteen minutes instead of three seconds.
|
|
18
|
+
// 2. Do the work off the response path, under the host's `waitUntil`.
|
|
19
|
+
// 3. Replace the placeholder with the real answer through {@link editOriginalInteractionResponse}.
|
|
20
|
+
//
|
|
21
|
+
// 🔴 THE EPHEMERAL FLAG RIDES THE DEFER, NOT THE EDIT, and that is not a style choice. Discord fixes
|
|
22
|
+
// a response's visibility at the FIRST response; the follow-up edit carries no flags and cannot
|
|
23
|
+
// change it. A defer sent without `flags 64` makes the eventual answer PUBLIC — on the grant door
|
|
24
|
+
// that would publish one person's credential decision into the channel. The flag is therefore part
|
|
25
|
+
// of the ack, and a test pins it.
|
|
26
|
+
//
|
|
27
|
+
// 🔴 THE FOLLOW-UP NEEDS NO BOT TOKEN AND NO NEW CONFIGURATION. The interaction webhook route is
|
|
28
|
+
// authenticated by the interaction TOKEN itself, and both halves of the address — `application_id`
|
|
29
|
+
// and `token` — ride the interaction payload Discord already signed. {@link interactionFollowUp}
|
|
30
|
+
// reads them from that verified envelope and returns `undefined` when either is missing, which is
|
|
31
|
+
// what lets a caller fall back to answering synchronously rather than deferring into silence.
|
|
32
|
+
//
|
|
33
|
+
// 🔴 THE INTERACTION TOKEN IS A CREDENTIAL. It authorises anyone holding it to write as the app for
|
|
34
|
+
// fifteen minutes. It never reaches a log line, an error string or an audit row from this module —
|
|
35
|
+
// `editOriginalInteractionResponse` builds the URL and reports only the HTTP status.
|
|
36
|
+
//
|
|
37
|
+
// EVERY WRITE GOES THROUGH `discordFetch` (the OPE-1236 seam), so the follow-up honours a 429's
|
|
38
|
+
// `retry_after` like every other Discord write. `discord-rest-seam-test.ts` §3 reds on a bare
|
|
39
|
+
// `fetch(` in this package, and this module holding its own would be exactly that.
|
|
40
|
+
|
|
41
|
+
import { discordFetch, type DiscordFetchDeps } from "./discord-rest.js";
|
|
42
|
+
|
|
43
|
+
const API = "https://discord.com/api/v10";
|
|
44
|
+
|
|
45
|
+
/** Discord interaction-response wire constants, defined locally for the same reason each door
|
|
46
|
+
* defines its own: this package holds no shared wire-constant module, and a door that cannot read
|
|
47
|
+
* its own numbers is harder to review than one that repeats them. */
|
|
48
|
+
const RESPONSE_DEFERRED_CHANNEL_MESSAGE = 5; // DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
|
|
49
|
+
const EPHEMERAL = 64; // MessageFlags.EPHEMERAL — only the clicker sees it
|
|
50
|
+
|
|
51
|
+
/** The address a follow-up edit is sent to: the app, and the interaction's own bearer token. */
|
|
52
|
+
export interface InteractionFollowUp {
|
|
53
|
+
readonly applicationId: string;
|
|
54
|
+
/** 🔴 A CREDENTIAL for fifteen minutes. Never logged, never audited, never in an error string. */
|
|
55
|
+
readonly token: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
59
|
+
return typeof v === "object" && v !== null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function str(v: unknown): string | undefined {
|
|
63
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The follow-up address carried by one verified interaction envelope, or `undefined` when either
|
|
68
|
+
* half is absent.
|
|
69
|
+
*
|
|
70
|
+
* 🔴 `undefined` MUST MEAN "DO NOT DEFER". A door that deferred without an address would ack with
|
|
71
|
+
* "thinking…" and then have no way to say anything else — a permanent placeholder, which is worse
|
|
72
|
+
* than the slow answer this whole module exists to replace. Callers fall back to the synchronous
|
|
73
|
+
* shape instead. Unreachable on a real Discord payload (both fields are always present); written as
|
|
74
|
+
* a real branch because the alternative failure is silent and permanent.
|
|
75
|
+
*/
|
|
76
|
+
export function interactionFollowUp(raw: unknown): InteractionFollowUp | undefined {
|
|
77
|
+
if (!isObject(raw)) return undefined;
|
|
78
|
+
const applicationId = str(raw["application_id"]);
|
|
79
|
+
const token = str(raw["token"]);
|
|
80
|
+
return applicationId === undefined || token === undefined ? undefined : { applicationId, token };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The immediate ack: "thinking…", visible only to the clicker. See the header for why the flag
|
|
84
|
+
* cannot be moved to the follow-up. */
|
|
85
|
+
export function deferredEphemeralResponse(): Record<string, unknown> {
|
|
86
|
+
return { type: RESPONSE_DEFERRED_CHANNEL_MESSAGE, data: { flags: EPHEMERAL } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Replace the deferred placeholder with the real answer.
|
|
91
|
+
*
|
|
92
|
+
* NEVER THROWS. This runs off the response path, under `waitUntil`, on a decision that has ALREADY
|
|
93
|
+
* been made and written — an unsent follow-up costs the person their explanation, and throwing here
|
|
94
|
+
* would additionally poison whatever else that background promise carries. Returns whether the edit
|
|
95
|
+
* landed, so a caller can log the difference.
|
|
96
|
+
*
|
|
97
|
+
* The URL's `@original` is the deferred message itself; Discord has no other handle on it.
|
|
98
|
+
*/
|
|
99
|
+
export async function editOriginalInteractionResponse(
|
|
100
|
+
followUp: InteractionFollowUp,
|
|
101
|
+
content: string,
|
|
102
|
+
deps: DiscordFetchDeps = {},
|
|
103
|
+
): Promise<boolean> {
|
|
104
|
+
try {
|
|
105
|
+
const res = await discordFetch(
|
|
106
|
+
"PATCH /webhooks/{application.id}/{interaction.token}/messages/@original",
|
|
107
|
+
`${API}/webhooks/${encodeURIComponent(followUp.applicationId)}/${encodeURIComponent(followUp.token)}/messages/@original`,
|
|
108
|
+
{
|
|
109
|
+
method: "PATCH",
|
|
110
|
+
// NO `authorization` HEADER, deliberately: the interaction token in the path IS the
|
|
111
|
+
// authorisation, and Discord rejects this route when a bot token is sent alongside it.
|
|
112
|
+
headers: { "content-type": "application/json" },
|
|
113
|
+
body: JSON.stringify({ content }),
|
|
114
|
+
},
|
|
115
|
+
deps,
|
|
116
|
+
);
|
|
117
|
+
return res.ok;
|
|
118
|
+
} catch {
|
|
119
|
+
// A 429 that outlasts the seam's attempts arrives as `RateLimitExhaustedError`; a transport
|
|
120
|
+
// failure arrives as itself. Neither may escape — see the paragraph above.
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The Discord SurfaceMessageOps — the bot-token REST transport for the ANCILLARY chat message ops
|
|
2
|
+
// generic core features dispatch through channels/surface-message-ops.ts (OPE-114 stage 4):
|
|
3
|
+
// - postText — a plain create-message (the hosted-artifact URL, the float notice, the approval
|
|
4
|
+
// decision notice). `replyToMessageId` anchors it via message_reference. Rides the
|
|
5
|
+
// reply feature's own write seam (discord-post.ts discordPostMessage — the 2000-char
|
|
6
|
+
// split + reply anchor already live there).
|
|
7
|
+
// - editMessage — PATCH /channels/{id}/messages/{mid} (an approval notice's decided-state rewrite).
|
|
8
|
+
// - postFiles — a hand-rolled multipart/form-data create-message (post_file's uploads). eve's
|
|
9
|
+
// Discord API is JSON-only, so payload_json + one files[i] part per file.
|
|
10
|
+
// - permalink — the message's own URL (request_access's PR body, OPE-303). NO network: Discord's
|
|
11
|
+
// message URL is a pure composition of ids we already hold.
|
|
12
|
+
// Registered by registerDiscordProvider(). The bot token is a SECRET — header only, never logged; on
|
|
13
|
+
// failure we surface the STATUS only.
|
|
14
|
+
|
|
15
|
+
import type { SurfaceMessageOps, OutboundFile } from "@open-everywhere/core/agent/adapter-api.js";
|
|
16
|
+
import { discordPostMessage } from "./discord-post.js";
|
|
17
|
+
import { discordFetch } from "./discord-rest.js";
|
|
18
|
+
|
|
19
|
+
const API = "https://discord.com/api/v10";
|
|
20
|
+
|
|
21
|
+
function botToken(): string {
|
|
22
|
+
const token = process.env.DISCORD_BOT_TOKEN;
|
|
23
|
+
if (!token) throw new Error("DISCORD_BOT_TOKEN is not set — the bot token is required to post to Discord.");
|
|
24
|
+
return token;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const discordMessageOps: SurfaceMessageOps = {
|
|
28
|
+
platform: "discord",
|
|
29
|
+
postText(channelId, text, opts) {
|
|
30
|
+
return discordPostMessage(channelId, text, opts?.replyToMessageId ? { replyToMessageId: opts.replyToMessageId } : undefined);
|
|
31
|
+
},
|
|
32
|
+
async editMessage(channelId, messageId, text) {
|
|
33
|
+
const res = await discordFetch("PATCH /channels/{id}/messages/{id}", `${API}/channels/${channelId}/messages/${messageId}`, {
|
|
34
|
+
method: "PATCH",
|
|
35
|
+
headers: { authorization: `Bot ${botToken()}`, "content-type": "application/json" },
|
|
36
|
+
// 🔴 `components: []` STRIPS THE BUTTONS, AND IT IS LOAD-BEARING (OPE-1260). Discord leaves a
|
|
37
|
+
// message's components untouched when a PATCH omits them, so without this line a card rewritten
|
|
38
|
+
// to a closed state kept four live buttons over copy saying the request was already over.
|
|
39
|
+
// Slack's own rewrite behaves this way already: `chat.update` sends a replacement `blocks`
|
|
40
|
+
// array and the buttons go with it.
|
|
41
|
+
//
|
|
42
|
+
// THE CALLER INVENTORY, because the strip is only safe while it holds. This arm is reached from
|
|
43
|
+
// ONE place — core's `channels/surface-message-ops.ts` `editSurfaceText` — whose only production
|
|
44
|
+
// caller is `access/credential-grant-card.ts` `rewriteGrantCard`. That helper now has FOUR
|
|
45
|
+
// callers: the sweep's expiry, the approve-time catalogue refusal, and (OPE-1278) the owner's
|
|
46
|
+
// own approve and decline. ALL FOUR RUN ONLY AFTER THE ASK ROW IS TERMINAL OR DECIDED — each
|
|
47
|
+
// one follows the CAS that closed the ask — so the strip still deletes no live control. An
|
|
48
|
+
// owner on Discord reaches this arm; an owner on Slack reaches `chat.update` instead.
|
|
49
|
+
// 🔴 A CALLER THAT MEANS "EDIT THE BODY, KEEP THE BUTTONS" MUST NOT USE THIS ARM — it has no way
|
|
50
|
+
// to say so, and this line would delete a live control. The core interface says the same.
|
|
51
|
+
body: JSON.stringify({ components: [], content: text }),
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) throw new Error(`Discord edit-message failed (${res.status}) for channel ${channelId}`);
|
|
54
|
+
},
|
|
55
|
+
/**
|
|
56
|
+
* `https://discord.com/channels/<guild|@me>/<channel>/<message>` — the canonical Discord message
|
|
57
|
+
* link, composed rather than fetched (there is no getPermalink API and none is needed).
|
|
58
|
+
*
|
|
59
|
+
* 🔴 THE DM CASE IS WHY `ref.dm` EXISTS AND A HEURISTIC WOULD NOT DO. A Discord DM binds
|
|
60
|
+
* `workspace` to the USER id (core identity/binding.ts), and a user id and a guild id are both
|
|
61
|
+
* snowflakes — indistinguishable by shape. Putting a user id in the guild slot yields a URL that
|
|
62
|
+
* looks right and resolves to nothing, which is worse than no link at all in a PR body an admin
|
|
63
|
+
* reads to decide a grant. So a DM composes the `@me` form, and a channel with no workspace
|
|
64
|
+
* resolves `undefined` rather than guessing either way.
|
|
65
|
+
*
|
|
66
|
+
* ⚠️ NOT YET REACHED BY THE ONE CORE CONSUMER, and the reason is in the BINDING, not here.
|
|
67
|
+
* `request_access` asks for a permalink to `binding.threadTs`, and this bundle's identity mapper
|
|
68
|
+
* (channels/discord-identity.ts) does not set that field at all — Discord threads are their own
|
|
69
|
+
* channel ids, so the binding carries a `channelId` and no per-message anchor. So a Discord
|
|
70
|
+
* access request still freezes no permalink, exactly as before, and its PR body falls back to
|
|
71
|
+
* naming the channel + scope. Giving the mapper a `threadTs` is not a free fix: that field also
|
|
72
|
+
* drives approval-card threading and the float notice, so it is its own change. Written down
|
|
73
|
+
* rather than left to be rediscovered — this method is correct and currently idle.
|
|
74
|
+
*/
|
|
75
|
+
async permalink(ref) {
|
|
76
|
+
const guild = ref.dm ? "@me" : ref.workspace;
|
|
77
|
+
return guild ? `https://discord.com/channels/${guild}/${ref.channelId}/${ref.messageId}` : undefined;
|
|
78
|
+
},
|
|
79
|
+
/**
|
|
80
|
+
* `{PUT|DELETE} /channels/{id}/messages/{mid}/reactions/{emoji}/@me` — the bot's own reaction,
|
|
81
|
+
* URL-encoded. This is the transport behind core's ✋ stop affordance (OPE-418).
|
|
82
|
+
*
|
|
83
|
+
* NEVER THROWS, deliberately, unlike the three ops above: a reaction is decoration beside work
|
|
84
|
+
* that is already running, so a missing permission must cost the turn its stop button, not the
|
|
85
|
+
* turn. A DELETE of a reaction that was never added 404s — that is the idempotent no-op, not an
|
|
86
|
+
* error.
|
|
87
|
+
*/
|
|
88
|
+
async setReaction(channelId, messageId, emoji, on) {
|
|
89
|
+
const encoded = encodeURIComponent(emoji);
|
|
90
|
+
try {
|
|
91
|
+
const res = await discordFetch(
|
|
92
|
+
on ? "PUT /channels/{id}/messages/{id}/reactions/{emoji}/@me" : "DELETE /channels/{id}/messages/{id}/reactions/{emoji}/@me",
|
|
93
|
+
`${API}/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`,
|
|
94
|
+
{ method: on ? "PUT" : "DELETE", headers: { authorization: `Bot ${botToken()}` } },
|
|
95
|
+
);
|
|
96
|
+
if (!res.ok && !(!on && res.status === 404)) {
|
|
97
|
+
console.warn(`[open-everywhere:discord] reaction ${on ? "add" : "remove"} failed (${res.status}) for message ${messageId}`);
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.warn(`[open-everywhere:discord] reaction ${on ? "add" : "remove"} error: ${(err as Error)?.message ?? String(err)}`);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
async postFiles(channelId, files: readonly OutboundFile[], comment) {
|
|
104
|
+
const form = new FormData();
|
|
105
|
+
const attachments = files.map((file, i) => ({ id: i, filename: file.filename, ...(file.caption ? { description: file.caption } : {}) }));
|
|
106
|
+
form.append("payload_json", JSON.stringify({ ...(comment ? { content: comment } : {}), attachments }));
|
|
107
|
+
files.forEach((file, i) => {
|
|
108
|
+
// A fresh ArrayBuffer copy so the Blob owns contiguous bytes regardless of the source view.
|
|
109
|
+
const ab = file.bytes.buffer.slice(file.bytes.byteOffset, file.bytes.byteOffset + file.bytes.byteLength) as ArrayBuffer;
|
|
110
|
+
form.append(`files[${i}]`, new Blob([ab], { type: file.contentType }), file.filename);
|
|
111
|
+
});
|
|
112
|
+
// No explicit content-type header — fetch derives the multipart boundary from the FormData.
|
|
113
|
+
const res = await discordFetch("POST /channels/{id}/messages", `${API}/channels/${channelId}/messages`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { authorization: `Bot ${botToken()}` },
|
|
116
|
+
body: form,
|
|
117
|
+
});
|
|
118
|
+
if (!res.ok) throw new Error(`Discord create-message (files) failed (HTTP ${res.status}) for channel ${channelId}`);
|
|
119
|
+
// Discord's create-message response carries the new message's own `id` — the same field
|
|
120
|
+
// discordPostOne reads for a plain text post (discord-post.ts). Hand it back so post_file's
|
|
121
|
+
// audit row can stamp `detail.id`, matching the reply path's shape (OPE-932). DELIBERATELY
|
|
122
|
+
// UNMARKED when absent, like discordPostOne: a 2xx means the message IS in the channel — this
|
|
123
|
+
// host just cannot name it, never an error worth failing an otherwise-successful upload over.
|
|
124
|
+
const body = (await res.json().catch(() => undefined)) as { id?: unknown } | undefined;
|
|
125
|
+
return typeof body?.id === "string" ? body.id : undefined;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The Discord UnifiedWakeOrigin codec — MOVED VERBATIM out of core's channels/wake-origin-codecs.ts
|
|
2
|
+
// (OPE-114 stage 4). STORED-DATA COMPATIBILITY IS THE CONTRACT: a Discord origin is JSON-serialised
|
|
3
|
+
// into the durable operational store and fail-closed re-validated on read, so every behaviour here
|
|
4
|
+
// is frozen by core's scripts/wake-origin-fixtures-test.ts (run from the app root with this bundle's
|
|
5
|
+
// boot wiring). Validator tolerances (typeof-only optional checks, extra unknown fields) are
|
|
6
|
+
// LOAD-BEARING for rows already on disk — never tighten them here.
|
|
7
|
+
//
|
|
8
|
+
// The `UnifiedWakeOrigin` TYPE union (including BOTH discord arms) stays in core — it is the wire
|
|
9
|
+
// schema for stored rows; only the codec BEHAVIOUR lives in the bundle, registered by
|
|
10
|
+
// registerDiscordProvider() (../register.ts).
|
|
11
|
+
//
|
|
12
|
+
// OPE-315 added a SECOND discord arm — a `dm: true`-discriminated DM/group-DM variant — so the
|
|
13
|
+
// validator became an XOR. Every existing GUILD row keeps validating byte for byte: the guild branch
|
|
14
|
+
// is the old predicate verbatim, and the additions are all on the new positive discriminant.
|
|
15
|
+
|
|
16
|
+
import type { UnifiedWakeOrigin, WakeOriginCodec } from "@open-everywhere/core/agent/adapter-api.js";
|
|
17
|
+
|
|
18
|
+
type DiscordOrigin = Extract<UnifiedWakeOrigin, { platform: "discord" }>;
|
|
19
|
+
|
|
20
|
+
/** COLON-FREE by construction (a task windowId is `[^:]+` — core channels/window-key.ts): any colon
|
|
21
|
+
* in a raw surface id is folded to `_`, deterministically, so spawn + ingress agree on identity. */
|
|
22
|
+
const noColon = (s: string): string => s.replace(/:/g, "_");
|
|
23
|
+
|
|
24
|
+
/** A DM-variant origin — narrowed on the positive discriminant, never on the ABSENCE of guildId. */
|
|
25
|
+
const isDmOrigin = (o: DiscordOrigin): o is Extract<DiscordOrigin, { dm: true }> => "dm" in o && o.dm === true;
|
|
26
|
+
|
|
27
|
+
export const discordWakeOriginCodec: WakeOriginCodec<DiscordOrigin> = {
|
|
28
|
+
validate: (o) => {
|
|
29
|
+
// TWO valid shapes, enforced as an XOR (never both, never neither) — the Linear arm's model.
|
|
30
|
+
// • GUILD: a non-empty guildId + NO `dm`. Unchanged from the original single shape, byte for
|
|
31
|
+
// byte: guildId is REQUIRED because the Discord privacy floor
|
|
32
|
+
// (discord-gateway.ts discordSingleWindowPrivacyOk) can never have admitted a guildless
|
|
33
|
+
// surface into a shared window, and this validator enforces the same invariant on the reply
|
|
34
|
+
// path. `messageId` and `autoThreadName` stay typeof-only optionals.
|
|
35
|
+
// • DM (OPE-315): `dm === true` + NO guildId + NO autoThreadName. A DM/group-DM reply needs
|
|
36
|
+
// only the channel id (POST /channels/{id}/messages under the bot token).
|
|
37
|
+
//
|
|
38
|
+
// 🔴 THE XOR IS DELIBERATELY BIDIRECTIONAL. "dm:true AND a guildId" is INVALID — not "the guild
|
|
39
|
+
// wins", not "the dm wins". That is the IMPERSONATION direction: a DM-shaped row that acquired
|
|
40
|
+
// guild authority would reach the guild-keyed threadRef, markThreadEngaged, and `discord/<guild>`
|
|
41
|
+
// vouch keys. And the guild arm still refuses guildless BY OMISSION (D1): nothing here relaxes
|
|
42
|
+
// it, so `divertDiscordWakeToUnifiedWindow`'s `guildId: msg.guildId ?? ""` fall-through stays
|
|
43
|
+
// invalid exactly as before — pinned by the two unmodified guildless-rejection fixture rows.
|
|
44
|
+
//
|
|
45
|
+
// autoThreadName is forbidden on the DM shape (D4) because the reply leg's auto-thread branch is
|
|
46
|
+
// guild-keyed (discord-post-arm.ts writes `discord/${origin.guildId}` engagement rows); a DM
|
|
47
|
+
// carrying it would mint `discord/undefined/...`. messageId is allowed — cosmetic
|
|
48
|
+
// message_reference anchoring with no guild-shaped consequence. That is the whole line: forbid
|
|
49
|
+
// exactly the fields with guild-keyed effects.
|
|
50
|
+
//
|
|
51
|
+
// OPE-1719 adds `thread` to the GUILD shape only, and it is a LITERAL-`true` optional rather
|
|
52
|
+
// than a typeof-boolean optional: the field's whole job is to be a POSITIVE thread signal, so
|
|
53
|
+
// `thread: false` must not validate and then read as "a thread that says it is not one". It is
|
|
54
|
+
// forbidden on the DM shape because a Discord DM has no threads at all — the same "forbid
|
|
55
|
+
// exactly the fields whose effects the shape cannot carry" line `autoThreadName` sits on.
|
|
56
|
+
if (typeof o.channelId !== "string" || o.channelId.length === 0) return false;
|
|
57
|
+
if (o.messageId !== undefined && typeof o.messageId !== "string") return false;
|
|
58
|
+
const isDm = o.dm === true && o.guildId === undefined && o.autoThreadName === undefined && o.thread === undefined;
|
|
59
|
+
const isGuild =
|
|
60
|
+
o.dm === undefined &&
|
|
61
|
+
typeof o.guildId === "string" &&
|
|
62
|
+
o.guildId.length > 0 &&
|
|
63
|
+
(o.autoThreadName === undefined || typeof o.autoThreadName === "string") &&
|
|
64
|
+
(o.thread === undefined || o.thread === true);
|
|
65
|
+
return isDm || isGuild;
|
|
66
|
+
},
|
|
67
|
+
// The DM namespace is `discord/dm/<channelId>` — a literal `dm` segment where a guild id sits on
|
|
68
|
+
// the guild shape. Collision-free because a Discord guild id is a decimal snowflake and can never
|
|
69
|
+
// be the string "dm". Relied on rather than enforced at the type level, so it is asserted in the
|
|
70
|
+
// fixtures oracle; if it were ever violated the blast radius is ROUTING (a shared thread-ownership
|
|
71
|
+
// row + taskWindowId prefix), not authority — a reply still goes to the stored origin.
|
|
72
|
+
threadRef: (origin) =>
|
|
73
|
+
isDmOrigin(origin)
|
|
74
|
+
? { channelKey: noColon(`discord/dm/${origin.channelId}`), threadKey: noColon(origin.channelId) }
|
|
75
|
+
: { channelKey: noColon(`discord/${origin.guildId}/${origin.channelId}`), threadKey: noColon(origin.channelId) },
|
|
76
|
+
// THE POSITIVE SIGNAL, and nothing else (OPE-1719). A Discord thread is its own channelId, so the
|
|
77
|
+
// id proves nothing; the gateway stamps `thread: true` at mint from the relay payload's channel
|
|
78
|
+
// type, and this reads exactly that stamp. Every other shape — a plain guild channel, a DM, and
|
|
79
|
+
// every stored row minted before the field existed — reads FALSE and routes to the standing seat,
|
|
80
|
+
// which is the fail-closed direction and byte-identical to the behaviour this replaced.
|
|
81
|
+
isThreadScoped: (origin) => !isDmOrigin(origin) && origin.thread === true,
|
|
82
|
+
containerId: (origin) => origin.channelId,
|
|
83
|
+
// THE PLACEMENT AXIS (OPE-1826). Discord is the SECOND surface whose post placement sits OUTSIDE
|
|
84
|
+
// its conversation identity, and until this member existed nothing said so: `threadRef` keys on the
|
|
85
|
+
// channel id alone (a Discord thread IS its own channel id), while the post arm hangs the answer on
|
|
86
|
+
// `messageId` as a `message_reference`. So two wakes in one channel are ONE conversation naming two
|
|
87
|
+
// different messages to reply under — Slack's `replyAnchorTs` shape exactly.
|
|
88
|
+
//
|
|
89
|
+
// 🔴 `messageId` ONLY, NEVER `autoThreadName`. The post arm reads both and only one is a PLACEMENT:
|
|
90
|
+
// `autoThreadName` decides whether the answer opens a NEW thread off the message, which MOVES the
|
|
91
|
+
// post out of the conversation it was asked in. The carry's own guarantee is that it can place a
|
|
92
|
+
// post and never move one, so the name is deliberately outside this axis and outside the carry
|
|
93
|
+
// below. A DM origin carries no message anchor at all — there is nothing to hang under in a 1:1 —
|
|
94
|
+
// and answers `undefined` through the same emptiness-is-absence read every other field takes.
|
|
95
|
+
placementRef: (origin) => (typeof origin.messageId === "string" && origin.messageId.length > 0 ? origin.messageId : undefined),
|
|
96
|
+
// THE CARRY (OPE-1826), narrowing-only and byte-for-byte the contract Slack's arm holds: fill a
|
|
97
|
+
// placement the target LACKS, never overwrite one, change no other field. A target that already
|
|
98
|
+
// names a message keeps it, because the model named THAT wake and answering it where it was asked
|
|
99
|
+
// is the honest reading. Core re-validates the result and re-tests the conversation, so this arm
|
|
100
|
+
// can fill a gap and cannot move a post even if this line were wrong.
|
|
101
|
+
carryPlacement: (target, source) =>
|
|
102
|
+
!(target.messageId && target.messageId.length > 0) && typeof source.messageId === "string" && source.messageId.length > 0
|
|
103
|
+
? ({ ...target, messageId: source.messageId } as DiscordOrigin)
|
|
104
|
+
: target,
|
|
105
|
+
};
|