@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.
Files changed (166) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +47 -0
  4. package/agent/access/discord-grant-click.ts +237 -0
  5. package/agent/approvals/discord-admin.ts +227 -0
  6. package/agent/approvals/discord-card.ts +248 -0
  7. package/agent/approvals/discord-flow.ts +469 -0
  8. package/agent/approvals/discord-scope-label.ts +27 -0
  9. package/agent/channels/discord-approval-card.ts +373 -0
  10. package/agent/channels/discord-backscroll.ts +593 -0
  11. package/agent/channels/discord-bot-name.ts +113 -0
  12. package/agent/channels/discord-channel-info.ts +83 -0
  13. package/agent/channels/discord-directory.ts +179 -0
  14. package/agent/channels/discord-dispatch-target.ts +103 -0
  15. package/agent/channels/discord-events.ts +175 -0
  16. package/agent/channels/discord-front.ts +355 -0
  17. package/agent/channels/discord-gateway.ts +3256 -0
  18. package/agent/channels/discord-identity.ts +269 -0
  19. package/agent/channels/discord-interaction-defer.ts +123 -0
  20. package/agent/channels/discord-message-ops.ts +127 -0
  21. package/agent/channels/discord-origin-codec.ts +105 -0
  22. package/agent/channels/discord-post-arm.ts +230 -0
  23. package/agent/channels/discord-post.ts +463 -0
  24. package/agent/channels/discord-read.ts +140 -0
  25. package/agent/channels/discord-rest.ts +85 -0
  26. package/agent/channels/discord.ts +244 -0
  27. package/agent/commands/discord-command-sync.ts +285 -0
  28. package/agent/commands/discord-commands.ts +291 -0
  29. package/agent/contributions.ts +31 -0
  30. package/agent/docs-generated.ts +15 -0
  31. package/agent/identity/discord-link-consent.ts +124 -0
  32. package/agent/ops.ts +14 -0
  33. package/agent/provider-events.ts +86 -0
  34. package/agent/register.ts +147 -0
  35. package/agent/surface-parity.ts +95 -0
  36. package/dist/agent/access/discord-grant-click.d.ts +25 -0
  37. package/dist/agent/access/discord-grant-click.d.ts.map +1 -0
  38. package/dist/agent/access/discord-grant-click.js +210 -0
  39. package/dist/agent/access/discord-grant-click.js.map +1 -0
  40. package/dist/agent/approvals/discord-admin.d.ts +63 -0
  41. package/dist/agent/approvals/discord-admin.d.ts.map +1 -0
  42. package/dist/agent/approvals/discord-admin.js +219 -0
  43. package/dist/agent/approvals/discord-admin.js.map +1 -0
  44. package/dist/agent/approvals/discord-card.d.ts +102 -0
  45. package/dist/agent/approvals/discord-card.d.ts.map +1 -0
  46. package/dist/agent/approvals/discord-card.js +217 -0
  47. package/dist/agent/approvals/discord-card.js.map +1 -0
  48. package/dist/agent/approvals/discord-flow.d.ts +109 -0
  49. package/dist/agent/approvals/discord-flow.d.ts.map +1 -0
  50. package/dist/agent/approvals/discord-flow.js +375 -0
  51. package/dist/agent/approvals/discord-flow.js.map +1 -0
  52. package/dist/agent/approvals/discord-scope-label.d.ts +3 -0
  53. package/dist/agent/approvals/discord-scope-label.d.ts.map +1 -0
  54. package/dist/agent/approvals/discord-scope-label.js +27 -0
  55. package/dist/agent/approvals/discord-scope-label.js.map +1 -0
  56. package/dist/agent/channels/discord-approval-card.d.ts +81 -0
  57. package/dist/agent/channels/discord-approval-card.d.ts.map +1 -0
  58. package/dist/agent/channels/discord-approval-card.js +303 -0
  59. package/dist/agent/channels/discord-approval-card.js.map +1 -0
  60. package/dist/agent/channels/discord-backscroll.d.ts +209 -0
  61. package/dist/agent/channels/discord-backscroll.d.ts.map +1 -0
  62. package/dist/agent/channels/discord-backscroll.js +505 -0
  63. package/dist/agent/channels/discord-backscroll.js.map +1 -0
  64. package/dist/agent/channels/discord-bot-name.d.ts +19 -0
  65. package/dist/agent/channels/discord-bot-name.d.ts.map +1 -0
  66. package/dist/agent/channels/discord-bot-name.js +98 -0
  67. package/dist/agent/channels/discord-bot-name.js.map +1 -0
  68. package/dist/agent/channels/discord-channel-info.d.ts +22 -0
  69. package/dist/agent/channels/discord-channel-info.d.ts.map +1 -0
  70. package/dist/agent/channels/discord-channel-info.js +65 -0
  71. package/dist/agent/channels/discord-channel-info.js.map +1 -0
  72. package/dist/agent/channels/discord-directory.d.ts +11 -0
  73. package/dist/agent/channels/discord-directory.d.ts.map +1 -0
  74. package/dist/agent/channels/discord-directory.js +170 -0
  75. package/dist/agent/channels/discord-directory.js.map +1 -0
  76. package/dist/agent/channels/discord-dispatch-target.d.ts +15 -0
  77. package/dist/agent/channels/discord-dispatch-target.d.ts.map +1 -0
  78. package/dist/agent/channels/discord-dispatch-target.js +99 -0
  79. package/dist/agent/channels/discord-dispatch-target.js.map +1 -0
  80. package/dist/agent/channels/discord-events.d.ts +58 -0
  81. package/dist/agent/channels/discord-events.d.ts.map +1 -0
  82. package/dist/agent/channels/discord-events.js +150 -0
  83. package/dist/agent/channels/discord-events.js.map +1 -0
  84. package/dist/agent/channels/discord-front.d.ts +115 -0
  85. package/dist/agent/channels/discord-front.d.ts.map +1 -0
  86. package/dist/agent/channels/discord-front.js +279 -0
  87. package/dist/agent/channels/discord-front.js.map +1 -0
  88. package/dist/agent/channels/discord-gateway.d.ts +783 -0
  89. package/dist/agent/channels/discord-gateway.d.ts.map +1 -0
  90. package/dist/agent/channels/discord-gateway.js +2591 -0
  91. package/dist/agent/channels/discord-gateway.js.map +1 -0
  92. package/dist/agent/channels/discord-identity.d.ts +110 -0
  93. package/dist/agent/channels/discord-identity.d.ts.map +1 -0
  94. package/dist/agent/channels/discord-identity.js +201 -0
  95. package/dist/agent/channels/discord-identity.js.map +1 -0
  96. package/dist/agent/channels/discord-interaction-defer.d.ts +33 -0
  97. package/dist/agent/channels/discord-interaction-defer.d.ts.map +1 -0
  98. package/dist/agent/channels/discord-interaction-defer.js +102 -0
  99. package/dist/agent/channels/discord-interaction-defer.js.map +1 -0
  100. package/dist/agent/channels/discord-message-ops.d.ts +3 -0
  101. package/dist/agent/channels/discord-message-ops.d.ts.map +1 -0
  102. package/dist/agent/channels/discord-message-ops.js +123 -0
  103. package/dist/agent/channels/discord-message-ops.js.map +1 -0
  104. package/dist/agent/channels/discord-origin-codec.d.ts +7 -0
  105. package/dist/agent/channels/discord-origin-codec.d.ts.map +1 -0
  106. package/dist/agent/channels/discord-origin-codec.js +98 -0
  107. package/dist/agent/channels/discord-origin-codec.js.map +1 -0
  108. package/dist/agent/channels/discord-post-arm.d.ts +3 -0
  109. package/dist/agent/channels/discord-post-arm.d.ts.map +1 -0
  110. package/dist/agent/channels/discord-post-arm.js +195 -0
  111. package/dist/agent/channels/discord-post-arm.js.map +1 -0
  112. package/dist/agent/channels/discord-post.d.ts +120 -0
  113. package/dist/agent/channels/discord-post.d.ts.map +1 -0
  114. package/dist/agent/channels/discord-post.js +426 -0
  115. package/dist/agent/channels/discord-post.js.map +1 -0
  116. package/dist/agent/channels/discord-read.d.ts +40 -0
  117. package/dist/agent/channels/discord-read.d.ts.map +1 -0
  118. package/dist/agent/channels/discord-read.js +128 -0
  119. package/dist/agent/channels/discord-read.js.map +1 -0
  120. package/dist/agent/channels/discord-rest.d.ts +33 -0
  121. package/dist/agent/channels/discord-rest.d.ts.map +1 -0
  122. package/dist/agent/channels/discord-rest.js +75 -0
  123. package/dist/agent/channels/discord-rest.js.map +1 -0
  124. package/dist/agent/channels/discord.d.ts +3 -0
  125. package/dist/agent/channels/discord.d.ts.map +1 -0
  126. package/dist/agent/channels/discord.js +230 -0
  127. package/dist/agent/channels/discord.js.map +1 -0
  128. package/dist/agent/commands/discord-command-sync.d.ts +66 -0
  129. package/dist/agent/commands/discord-command-sync.d.ts.map +1 -0
  130. package/dist/agent/commands/discord-command-sync.js +224 -0
  131. package/dist/agent/commands/discord-command-sync.js.map +1 -0
  132. package/dist/agent/commands/discord-commands.d.ts +45 -0
  133. package/dist/agent/commands/discord-commands.d.ts.map +1 -0
  134. package/dist/agent/commands/discord-commands.js +266 -0
  135. package/dist/agent/commands/discord-commands.js.map +1 -0
  136. package/dist/agent/contributions.d.ts +17 -0
  137. package/dist/agent/contributions.d.ts.map +1 -0
  138. package/dist/agent/contributions.js +31 -0
  139. package/dist/agent/contributions.js.map +1 -0
  140. package/dist/agent/docs-generated.d.ts +3 -0
  141. package/dist/agent/docs-generated.d.ts.map +1 -0
  142. package/dist/agent/docs-generated.js +13 -0
  143. package/dist/agent/docs-generated.js.map +1 -0
  144. package/dist/agent/identity/discord-link-consent.d.ts +13 -0
  145. package/dist/agent/identity/discord-link-consent.d.ts.map +1 -0
  146. package/dist/agent/identity/discord-link-consent.js +111 -0
  147. package/dist/agent/identity/discord-link-consent.js.map +1 -0
  148. package/dist/agent/ops.d.ts +2 -0
  149. package/dist/agent/ops.d.ts.map +1 -0
  150. package/dist/agent/ops.js +13 -0
  151. package/dist/agent/ops.js.map +1 -0
  152. package/dist/agent/provider-events.d.ts +3 -0
  153. package/dist/agent/provider-events.d.ts.map +1 -0
  154. package/dist/agent/provider-events.js +78 -0
  155. package/dist/agent/provider-events.js.map +1 -0
  156. package/dist/agent/register.d.ts +5 -0
  157. package/dist/agent/register.d.ts.map +1 -0
  158. package/dist/agent/register.js +125 -0
  159. package/dist/agent/register.js.map +1 -0
  160. package/dist/agent/surface-parity.d.ts +3 -0
  161. package/dist/agent/surface-parity.d.ts.map +1 -0
  162. package/dist/agent/surface-parity.js +90 -0
  163. package/dist/agent/surface-parity.js.map +1 -0
  164. package/package.json +185 -0
  165. package/tsconfig.build.json +22 -0
  166. package/tsconfig.json +15 -0
@@ -0,0 +1,463 @@
1
+ // Discord REST post for the `reply` tool (tools/reply.ts). This is the reply feature's own
2
+ // Discord write seam — the parallel of slack-read.ts for the read tools, and of
3
+ // slack-events.ts's `slackForm` for the Slack write path. It is kept separate from
4
+ // channels/discord-gateway.ts (which has its OWN private create-message used by the completion
5
+ // post-back) so the tools don't pull the whole gateway channel module into their import graph;
6
+ // the auth pattern here mirrors that module's exactly. {@link discordEditMessage} is the same seam's
7
+ // edit primitive, for the `edit_reply` tool (OPE-793).
8
+ //
9
+ // `Bot <token>` is Discord's scheme; the bot token is a SECRET — it rides only in the header,
10
+ // NEVER in a log line, error string, or return value. Calls throw a clean, token-free error on
11
+ // failure so the tool surfaces an honest result to the model (unlike the gateway's fail-silent
12
+ // post-back, a tool must report whether the post actually landed).
13
+
14
+ import { markNotApplied } from "@open-everywhere/core/agent/adapter-api.js";
15
+ import { discordFetch } from "./discord-rest.js";
16
+
17
+ const API = "https://discord.com/api/v10";
18
+
19
+ /** Discord's hard per-message character cap — the ceiling {@link splitForDiscord} splits to and the
20
+ * one {@link discordEditMessage} refuses past, stated once so the two can never disagree. */
21
+ export const DISCORD_MESSAGE_MAX = 2000;
22
+
23
+ function botHeaders(token: string): Record<string, string> {
24
+ return { authorization: `Bot ${token}`, "content-type": "application/json" };
25
+ }
26
+
27
+ function botToken(): string {
28
+ const token = process.env.DISCORD_BOT_TOKEN;
29
+ if (!token) {
30
+ throw new Error("DISCORD_BOT_TOKEN is not set — the reply tool needs the bot token to post to Discord.");
31
+ }
32
+ return token;
33
+ }
34
+
35
+ // Discord caps a message at 2000 chars. `splitForDiscord` breaks an over-cap reply into parts
36
+ // that each fit, honouring three rules the reply-tool family needs (OPE-41):
37
+ // 1. BOUNDARY-PREFERRING — cut at a paragraph/line break when one is in reach, else a sentence
38
+ // end, else a word boundary, and only hard-cut mid-word as a last resort. So a split lands
39
+ // between thoughts, not inside one.
40
+ // 2. FENCE-SAFE — never leave a ```code fence dangling across the seam. When a cut falls inside
41
+ // an open fence, the part is CLOSED with ``` and the next part RE-OPENS the same fence (with
42
+ // its language), so every part renders as valid markdown on its own.
43
+ // 3. NUMBERED ONLY WHEN >2 — a 1- or 2-part reply reads fine without ceremony; 3+ parts get a
44
+ // compact `[i/N]` header so the reader knows the order and that more is coming.
45
+ // Guarantees forward progress and loses no CONTENT (a single boundary whitespace char is dropped
46
+ // at each seam, exactly as the old splitter did; the fence markers + `[i/N]` headers are the only
47
+ // added text). Lives HERE (the leaf write seam) so the unified reply arm (unified-reply.ts) can
48
+ // split without importing the whole gateway channel module; discord-gateway.ts re-exports it, so
49
+ // every existing importer keeps compiling untouched. Pure — exported for the test oracle.
50
+ export function splitForDiscord(text: string, max = DISCORD_MESSAGE_MAX): string[] {
51
+ if (text.length <= max) return [text];
52
+ // Pass 1: split with the full budget. If it fits in ≤2 parts, no numbering ceremony.
53
+ const bare = splitRespectingFences(text, max);
54
+ if (bare.length <= 2) return bare;
55
+ // Pass 2: 3+ parts → number them. Re-split with a reduced budget so the `[i/N]\n` header still
56
+ // fits under the cap, then prefix the real header (N is known only after the re-split).
57
+ const numbered = splitRespectingFences(text, max - NUMBER_HEADER_RESERVE);
58
+ const n = numbered.length;
59
+ return numbered.map((part, i) => `[${i + 1}/${n}]\n${part}`);
60
+ }
61
+
62
+ // Reserve for the `[i/N]\n` header — `[9999/9999]\n` is 12 chars, more than any realistic reply.
63
+ const NUMBER_HEADER_RESERVE = 12;
64
+ const FENCE_CLOSE = "\n```";
65
+ const FENCE_LINE = /^\s*```/;
66
+
67
+ /** The fence opener (e.g. "```ts") still open after walking `body` from `start`, or null. Markdown
68
+ * fences toggle (they don't nest), so a ``` line either opens — capturing its language — or closes. */
69
+ function fenceStateAfter(start: string | null, body: string): string | null {
70
+ let opener = start;
71
+ for (const line of body.split("\n")) {
72
+ if (FENCE_LINE.test(line)) opener = opener === null ? line.trim() : null;
73
+ }
74
+ return opener;
75
+ }
76
+
77
+ /** Choose the cut index in (from, hardEnd], preferring a line break, then a sentence end, then a
78
+ * word boundary, then a hard cut. `drop` is how many boundary whitespace chars to skip after the
79
+ * cut so the seam doesn't duplicate them (a newline/space that separated the two parts). */
80
+ function chooseCut(text: string, from: number, hardEnd: number): { end: number; drop: number } {
81
+ if (hardEnd >= text.length) return { end: text.length, drop: 0 };
82
+ const nl = text.lastIndexOf("\n", hardEnd);
83
+ if (nl > from) return { end: nl, drop: 1 }; // line/paragraph boundary (blank lines are newlines)
84
+ // Sentence end: the last ". "/"! "/"? " whose terminator sits within (from, hardEnd].
85
+ let sentence = -1;
86
+ for (const sep of [". ", "! ", "? "]) {
87
+ const i = text.lastIndexOf(sep, hardEnd - 1);
88
+ if (i > from && i + 1 < hardEnd) sentence = Math.max(sentence, i + 1); // keep the punctuation
89
+ }
90
+ if (sentence > from) return { end: sentence, drop: 1 };
91
+ const sp = text.lastIndexOf(" ", hardEnd);
92
+ if (sp > from) return { end: sp, drop: 1 };
93
+ return { end: hardEnd, drop: 0 }; // no boundary in reach → hard cut mid-word
94
+ }
95
+
96
+ /** Split `text` into parts whose RENDERED length (fence reopen prefix + body + fence close suffix)
97
+ * never exceeds `renderBudget`, re-opening any fence left open across a seam. */
98
+ function splitRespectingFences(text: string, renderBudget: number): string[] {
99
+ const parts: string[] = [];
100
+ let carried: string | null = null; // fence opener carried INTO the current part
101
+ let pos = 0;
102
+ while (pos < text.length) {
103
+ const reopen = carried !== null ? `${carried}\n` : "";
104
+ const avail = Math.max(1, renderBudget - reopen.length);
105
+ let cut = chooseCut(text, pos, Math.min(pos + avail, text.length));
106
+ let body = text.slice(pos, cut.end);
107
+ let endOpener = fenceStateAfter(carried, body);
108
+ let suffix = endOpener !== null ? FENCE_CLOSE : "";
109
+ // A still-open fence adds a closing ``` — if that tips the part over budget, recut shorter.
110
+ if (reopen.length + body.length + suffix.length > renderBudget && body.length > 1) {
111
+ cut = chooseCut(text, pos, Math.min(pos + avail - suffix.length, text.length));
112
+ body = text.slice(pos, cut.end);
113
+ endOpener = fenceStateAfter(carried, body);
114
+ suffix = endOpener !== null ? FENCE_CLOSE : "";
115
+ }
116
+ parts.push(`${reopen}${body}${suffix}`);
117
+ carried = endOpener;
118
+ pos = cut.end + cut.drop;
119
+ }
120
+ return parts;
121
+ }
122
+
123
+ /**
124
+ * Post a message to a Discord channel — `POST /channels/{id}/messages`. Returns the FIRST
125
+ * message's id (the audit/output anchor). Throws (never a null id) on a missing token, a non-ok
126
+ * status, or a response missing an id — the tool relays that to the model.
127
+ *
128
+ * OVER-CAP SPLIT (OPE-41): `content` longer than Discord's 2000-char limit is split via
129
+ * {@link splitForDiscord} and posted as SEVERAL messages in order, so the reply-tool path
130
+ * (reply-post.ts `dispatchReply`, which hands us the raw text) no longer hard-fails on a long
131
+ * reply the way a single over-cap POST would (400). A reply ANCHOR (`opts.replyToMessageId`)
132
+ * attaches only to the FIRST part — the continuation parts thread naturally after it. A
133
+ * within-cap `content` is one POST with the byte-identical body it always sent.
134
+ *
135
+ * `opts.replyToMessageId` anchors the (first) post as a Discord REPLY to that message
136
+ * (message_reference; fail_if_not_exists=false so a deleted anchor degrades to a plain post
137
+ * rather than a 400) — used by the single-window reply arm so the answer visibly attaches to
138
+ * the triggering mention.
139
+ */
140
+ export async function discordPostMessage(channelId: string, content: string, opts?: { replyToMessageId?: string }): Promise<string> {
141
+ const parts = splitForDiscord(content);
142
+ let firstId: string | undefined;
143
+ for (const part of parts) {
144
+ if (!part.trim()) continue; // never POST an invisible empty part (Discord 400s on empty content)
145
+ // Anchor the reply only on the FIRST posted part; continuations follow it in the channel.
146
+ const id = await discordPostOne(channelId, part, firstId === undefined ? opts?.replyToMessageId : undefined);
147
+ firstId ??= id;
148
+ }
149
+ if (firstId === undefined) {
150
+ // DEFINITIVE (OPE-821): every part was blank, so no POST was ever issued and the room is
151
+ // unchanged. The post spine may release its dedupe claim on this.
152
+ throw markNotApplied(new Error(`Discord create-message had no content to post for channel ${channelId}`));
153
+ }
154
+ return firstId;
155
+ }
156
+
157
+ /**
158
+ * Open (or reuse) the 1:1 DM channel with `userId` — `POST /users/@me/channels` with a
159
+ * `recipient_id` (OPE-623). Returns the DM channel id, which every ordinary channel call above then
160
+ * addresses; Discord itself is idempotent here, so a second call for the same recipient returns the
161
+ * SAME channel rather than a second one.
162
+ *
163
+ * WHO CAN BE REACHED, AND WHAT A FAILURE MEANS. Discord delivers a bot DM only when the recipient
164
+ * shares a server with the bot AND has not turned off direct messages from server members. Neither
165
+ * is knowable in advance, so the refusal arrives as a 403 on the POST — an honest "this person
166
+ * cannot be DM'd", not a bug. It THROWS rather than resolving null, because every caller
167
+ * (`deliverDirectMessage`) has to be able to tell a delivered consent ask from an undelivered one:
168
+ * treating "could not open the channel" as a successful send would tell an initiator that somebody
169
+ * was asked to confirm when nobody was.
170
+ *
171
+ * Token handling matches every call in this module: the bot token rides only the header, and no
172
+ * response body ever reaches an error string.
173
+ */
174
+ export async function discordOpenDmChannel(userId: string): Promise<string> {
175
+ const res = await discordFetch("POST /users/@me/channels", `${API}/users/@me/channels`, {
176
+ method: "POST",
177
+ headers: botHeaders(botToken()),
178
+ body: JSON.stringify({ recipient_id: userId }),
179
+ });
180
+ if (!res.ok) {
181
+ // DEFINITIVE (OPE-821): no message was posted — the channel was never opened — so a caller may
182
+ // retry or fall back without any risk of a double delivery. The status rides as a NUMBER, which
183
+ // carries no token and no response body.
184
+ throw markNotApplied(
185
+ Object.assign(new Error(`Discord could not open a DM channel (${res.status}) with user ${userId}`), { status: res.status }),
186
+ );
187
+ }
188
+ const body = (await res.json()) as { id?: unknown };
189
+ const id = typeof body?.id === "string" ? body.id.trim() : "";
190
+ if (!id) throw markNotApplied(new Error(`Discord open-DM returned no channel id for user ${userId}`));
191
+ return id;
192
+ }
193
+
194
+ // Discord component wire constants (stable public API integers). Defined locally for the same
195
+ // reason approvals/discord-card.ts defines its own: importing eve's channel module for two integers
196
+ // would widen this leaf write seam's graph.
197
+ const COMPONENT_ACTION_ROW = 1;
198
+ const COMPONENT_BUTTON = 2;
199
+ const BUTTON_STYLE_SUCCESS = 3; // green — the affirmative answer
200
+ const BUTTON_STYLE_DANGER = 4; // red — the rejecting answer
201
+
202
+ /** One button on a DM, in Discord's own shape. `custom_id` is the CALLER's opaque routing key. */
203
+ export interface DiscordDmButton {
204
+ readonly customId: string;
205
+ readonly label: string;
206
+ readonly intent: "affirm" | "reject";
207
+ }
208
+
209
+ /**
210
+ * Post `content` to a DM channel with a row of BUTTONS under it (OPE-622) — one
211
+ * `POST /channels/{id}/messages` carrying a components ACTION_ROW.
212
+ *
213
+ * 🔴 NO SPLIT, AND THAT IS DELIBERATE. `discordPostMessage` splits an over-cap reply into several
214
+ * messages; buttons cannot be split, and a row attached to the wrong part would sit under a
215
+ * fragment. So this refuses past Discord's 2000-character cap rather than truncating or fanning out,
216
+ * and both callers post core-owned copy: the identity consent ask, and the bridged approval card
217
+ * (channels/discord-post-arm.ts), whose own caller falls back to the splitting path past the cap.
218
+ *
219
+ * `opts.replyToMessageId` hangs the message under the one it answers, through the same
220
+ * `message_reference` the plain post path uses and with the same `fail_if_not_exists: false` degrade
221
+ * when the trigger is gone (OPE-1720 — a card posted onto a unified origin must land where the
222
+ * conversation is looking, exactly as its prose sibling does). ABSENT ⇒ a plain channel post.
223
+ *
224
+ * The bot token rides only the header, like every call in this module.
225
+ */
226
+ export async function discordPostMessageWithButtons(
227
+ channelId: string,
228
+ content: string,
229
+ buttons: readonly DiscordDmButton[],
230
+ opts?: { readonly replyToMessageId?: string },
231
+ ): Promise<string> {
232
+ if (content.length > DISCORD_MESSAGE_MAX) {
233
+ throw markNotApplied(
234
+ new Error(`Discord create-message with buttons cannot split ${content.length} characters (cap ${DISCORD_MESSAGE_MAX}) for channel ${channelId}`),
235
+ );
236
+ }
237
+ const res = await discordFetch("POST /channels/{id}/messages", `${API}/channels/${channelId}/messages`, {
238
+ method: "POST",
239
+ headers: botHeaders(botToken()),
240
+ body: JSON.stringify({
241
+ content,
242
+ ...(opts?.replyToMessageId ? { message_reference: { message_id: opts.replyToMessageId, fail_if_not_exists: false } } : {}),
243
+ components: [
244
+ {
245
+ type: COMPONENT_ACTION_ROW,
246
+ components: buttons.map((b) => ({
247
+ type: COMPONENT_BUTTON,
248
+ style: b.intent === "affirm" ? BUTTON_STYLE_SUCCESS : BUTTON_STYLE_DANGER,
249
+ label: b.label,
250
+ custom_id: b.customId,
251
+ })),
252
+ },
253
+ ],
254
+ }),
255
+ });
256
+ if (!res.ok) {
257
+ throw Object.assign(new Error(`Discord create-message with buttons failed (${res.status}) for channel ${channelId}`), { status: res.status });
258
+ }
259
+ const body = (await res.json()) as { id?: unknown };
260
+ const id = typeof body?.id === "string" ? body.id : undefined;
261
+ // 🔴 DELIBERATELY UNMARKED, like discordPostOne's own missing-id branch: Discord answered 2xx, so
262
+ // the message IS in the channel — this host just cannot name it. Calling that definitive would
263
+ // license a retry that posts the ask twice.
264
+ if (!id) throw new Error(`Discord create-message with buttons returned no message id for channel ${channelId}`);
265
+ return id;
266
+ }
267
+
268
+ /** One raw `POST /channels/{id}/messages`. Kept private — every caller goes through
269
+ * {@link discordPostMessage} so the 2000-char split is never bypassed. */
270
+ async function discordPostOne(channelId: string, content: string, replyToMessageId?: string): Promise<string> {
271
+ const res = await discordFetch("POST /channels/{id}/messages", `${API}/channels/${channelId}/messages`, {
272
+ method: "POST",
273
+ headers: botHeaders(botToken()),
274
+ body: JSON.stringify({
275
+ content,
276
+ ...(replyToMessageId ? { message_reference: { message_id: replyToMessageId, fail_if_not_exists: false } } : {}),
277
+ }),
278
+ });
279
+ if (!res.ok) {
280
+ // The HTTP status rides as a NUMBER on the error (OPE-821), exactly as the edit arm below does
281
+ // it. The post spine reads it to tell a Discord REFUSAL (404 gone, 403 forbidden — nothing
282
+ // posted) from a 5xx or a timeout, which may have posted. A number carries no token and no
283
+ // response body, so this stays inside the token-free rule the header states.
284
+ throw Object.assign(new Error(`Discord create-message failed (${res.status}) for channel ${channelId}`), { status: res.status });
285
+ }
286
+ const body = (await res.json()) as { id?: unknown };
287
+ const id = typeof body?.id === "string" ? body.id : undefined;
288
+ // 🔴 DELIBERATELY UNMARKED. Discord answered 2xx, so the message IS in the channel — this host just
289
+ // cannot name it. Calling that definitive would license a retry that posts the message twice.
290
+ if (!id) throw new Error(`Discord create-message returned no message id for channel ${channelId}`);
291
+ return id;
292
+ }
293
+
294
+ /**
295
+ * Rewrite one already-posted message — `PATCH /channels/{id}/messages/{mid}` (OPE-793). Discord
296
+ * renders an "(edited)" marker, so the change is visible in the room.
297
+ *
298
+ * 🔴 NO SPLIT, AND THAT IS A REAL LIMIT RATHER THAN AN OMISSION. A message is ONE message: an
299
+ * over-cap edit cannot become several the way an over-cap POST can, so text past Discord's 2000-char
300
+ * cap is REFUSED here with an honest error the model can act on. Refusing beats truncating (silent
301
+ * data loss) and beats posting continuation messages (an edit must add nothing to the room).
302
+ *
303
+ * Ownership is decided upstream against the own-post ledger (core's channels/reply-edit.ts); Discord
304
+ * refusing to edit another author's message is the backstop under it. Token handling mirrors
305
+ * {@link discordPostMessage}: the bot token rides only the header and no response body ever reaches
306
+ * an error string.
307
+ */
308
+ export async function discordEditMessage(channelId: string, messageId: string, content: string): Promise<void> {
309
+ if (content.length > DISCORD_MESSAGE_MAX) {
310
+ // DEFINITIVE (OPE-802): the call never leaves this host, so the message did not change and the
311
+ // model may post a new reply. The HTTP rejection below carries a status instead, which classes
312
+ // itself.
313
+ throw markNotApplied(
314
+ new Error(
315
+ `edit_reply couldn't update the Discord message — the new text is ${content.length} characters and Discord caps one message at ${DISCORD_MESSAGE_MAX}. A message cannot be split by an edit; shorten it, or post a new reply.`,
316
+ ),
317
+ );
318
+ }
319
+ const res = await discordFetch("PATCH /channels/{id}/messages/{id}", `${API}/channels/${channelId}/messages/${messageId}`, {
320
+ method: "PATCH",
321
+ headers: botHeaders(botToken()),
322
+ body: JSON.stringify({ content }),
323
+ });
324
+ if (!res.ok) {
325
+ // The HTTP status rides as a NUMBER on the error, not only inside the message. The edit spine
326
+ // (core's channels/reply-edit.ts) reads it for the failure audit row, so a platform rejection is
327
+ // visible in the trail with the status that caused it (OPE-798). A number carries no token and
328
+ // no response body, so this stays inside the token-free rule the header states.
329
+ throw Object.assign(new Error(`edit_reply couldn't update the Discord message (${res.status}) in channel ${channelId}`), {
330
+ status: res.status,
331
+ });
332
+ }
333
+ }
334
+
335
+ /**
336
+ * REACT (OPE-987): bare Slack-style shortcode name → the Discord-native unicode character this arm
337
+ * reacts with. The `react` tool's own `EMOJI_NAME_PATTERN` (core tools/react.ts) already restricts
338
+ * every model-given name to lowercase letters/digits/`_`/`+`/`-` — no colon, no raw unicode — so this
339
+ * table is the ONE place a bare name becomes something Discord's reaction endpoint accepts (a real
340
+ * unicode emoji, URL-encoded). Scoped to the vocabulary the noise doctrine and Tag's own acknowledge/
341
+ * status vocabulary actually reach for, mirroring GitHub's own answer to the same problem
342
+ * (github-post-arm.ts `GITHUB_REACTION_MAP`) rather than pulling in a general shortcode library —
343
+ * Discord's own reaction endpoint takes ANY unicode emoji, so this table is a vocabulary CHOICE, not
344
+ * a platform limit; growing it costs one line, never a migration.
345
+ */
346
+ const DISCORD_EMOJI_MAP: Readonly<Record<string, string>> = {
347
+ thumbsup: "👍",
348
+ "+1": "👍",
349
+ thumbsdown: "👎",
350
+ "-1": "👎",
351
+ eyes: "👀",
352
+ white_check_mark: "✅",
353
+ heavy_check_mark: "✔️",
354
+ x: "❌",
355
+ tada: "🎉",
356
+ rocket: "🚀",
357
+ heart: "❤️",
358
+ fire: "🔥",
359
+ clap: "👏",
360
+ wave: "👋",
361
+ raised_hand: "✋",
362
+ raised_hands: "🙌",
363
+ pray: "🙏",
364
+ laughing: "😆",
365
+ joy: "😂",
366
+ confused: "😕",
367
+ thinking_face: "🤔",
368
+ hourglass: "⏳",
369
+ hourglass_flowing_sand: "⏳",
370
+ question: "❓",
371
+ exclamation: "❗",
372
+ warning: "⚠️",
373
+ octagonal_sign: "🛑",
374
+ "100": "💯",
375
+ };
376
+
377
+ /**
378
+ * REACT (OPE-987): `PUT /channels/{id}/messages/{mid}/reactions/{emoji}/@me` under the bot token —
379
+ * the SAME primitive `discordMessageOps.setReaction` calls for the host-placed ✋ glyph, but this one
380
+ * THROWS on failure (unlike that primitive, which is deliberately fail-soft — see its own header):
381
+ * a model-invoked `react` is a verb the model gets an honest answer from, not decoration beside work
382
+ * that is already running.
383
+ *
384
+ * An unmapped emoji name refuses DEFINITIVE (`markNotApplied`, OPE-825) before any byte leaves this
385
+ * host — the same shape GitHub's fixed content enum refuses with. A rejected PUT carries the HTTP
386
+ * status on the thrown error (token-free), so `platformFailureClass` can classify it without any
387
+ * string matching.
388
+ */
389
+ export async function discordAddReaction(channelId: string, messageId: string, emojiName: string): Promise<void> {
390
+ const unicode = DISCORD_EMOJI_MAP[emojiName.toLowerCase()];
391
+ if (!unicode) {
392
+ throw markNotApplied(
393
+ new Error(
394
+ `react couldn't post to Discord — "${emojiName}" has no Discord emoji equivalent here; this deployment supports: ${Object.keys(DISCORD_EMOJI_MAP).join(", ")}.`,
395
+ ),
396
+ );
397
+ }
398
+ const res = await discordFetch(
399
+ "PUT /channels/{id}/messages/{id}/reactions/{emoji}/@me",
400
+ `${API}/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(unicode)}/@me`,
401
+ { method: "PUT", headers: botHeaders(botToken()) },
402
+ );
403
+ if (!res.ok) {
404
+ throw Object.assign(new Error(`react couldn't post to Discord — ${res.status} on channel ${channelId}`), { status: res.status });
405
+ }
406
+ }
407
+
408
+ /**
409
+ * READ-ONLY: the id of the thread that already hangs off `messageId`, or null when there is none.
410
+ *
411
+ * 🔴 IT NEVER CREATES A THREAD, and that is the whole reason it exists next to
412
+ * {@link discordThreadForMessage}. The EDIT path needs the same auto-thread the POST path opened
413
+ * (OPE-798), but at edit time a create would open a NEW empty thread that cannot hold the message
414
+ * being edited — a visible side effect in the room, and still a 404. So the edit path asks only
415
+ * "where did the reply land", and degrades to the base channel when the answer is "nowhere".
416
+ *
417
+ * Fail-soft to null on ANY failure (deleted trigger, missing permission, rate limit), matching the
418
+ * create-or-find sibling. Token handling is identical: the bot token rides only the header.
419
+ */
420
+ export async function discordExistingThreadForMessage(channelId: string, messageId: string): Promise<string | null> {
421
+ try {
422
+ const msgRes = await discordFetch("GET /channels/{id}/messages/{id}", `${API}/channels/${channelId}/messages/${messageId}`, {
423
+ headers: botHeaders(botToken()),
424
+ });
425
+ if (!msgRes.ok) return null;
426
+ const msg = (await msgRes.json()) as { thread?: { id?: unknown } };
427
+ return typeof msg?.thread?.id === "string" ? msg.thread.id : null;
428
+ } catch {
429
+ return null;
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Open (or find) the thread hanging off a message — the unified reply arm's auto-thread leg.
435
+ * `POST /channels/{id}/messages/{mid}/threads`; when Discord answers THREAD_ALREADY_CREATED
436
+ * (a second reply to the same wake, or a crash-retry), falls back to reading the message's
437
+ * existing `thread.id`. Returns null on ANY failure (missing permission, deleted trigger,
438
+ * rate limit) — the caller degrades to the plain in-channel reply, never blocks the post.
439
+ * Token handling mirrors discordPostMessage: the bot token rides only the header, and no
440
+ * response body ever reaches an error string or return value.
441
+ */
442
+ export async function discordThreadForMessage(channelId: string, messageId: string, name: string): Promise<string | null> {
443
+ try {
444
+ const res = await discordFetch(
445
+ "POST /channels/{id}/messages/{id}/threads",
446
+ `${API}/channels/${channelId}/messages/${messageId}/threads`,
447
+ {
448
+ method: "POST",
449
+ headers: botHeaders(botToken()),
450
+ body: JSON.stringify({ name: name.slice(0, 100) || "thread", auto_archive_duration: 1440 }),
451
+ },
452
+ );
453
+ if (res.ok) {
454
+ const body = (await res.json()) as { id?: unknown };
455
+ return typeof body?.id === "string" ? body.id : null;
456
+ }
457
+ // Already threaded (idempotency): read the existing thread id off the message itself.
458
+ return await discordExistingThreadForMessage(channelId, messageId);
459
+ } catch {
460
+ return null;
461
+ }
462
+ }
463
+
@@ -0,0 +1,140 @@
1
+ // The Discord read adapter — EXTRACTED from core channels/channel-read.ts (OPE-114 stage 4). A real
2
+ // `GET /channels/{id}/messages` (bot token) mapped through linesFromDiscordMessages into the neutral
3
+ // RenderedLine[]; registered via registerReadAdapter from registerDiscordProvider(). Discord threads
4
+ // ARE their own channel ids (one endpoint covers a base channel AND a thread — see
5
+ // discord-backscroll.ts), so readThread reads the thread's OWN id, carried in `thread_ts` (the
6
+ // generic verb's per-surface "thread identifier"). The generic per-surface allowlist gate
7
+ // (assertSurfaceAllowed) is SHARED from core (barrel) — it is platform-parameterised.
8
+ //
9
+ // The bot token is a SECRET — header only, never logged. Fail-LOUD (unlike fail-soft backscroll
10
+ // enrichment): a read must surface its error. The requester is injectable for offline tests.
11
+
12
+ import {
13
+ assertSurfaceAllowed,
14
+ bindFrom,
15
+ resolvePartition,
16
+ refutedReadAuthorIds,
17
+ type BindableContext,
18
+ type RenderedLine,
19
+ type WakeReadAddress,
20
+ } from "@open-everywhere/core/agent/adapter-api.js";
21
+ import { linesFromDiscordMessages, type DiscordRoleProbe } from "./discord-backscroll.js";
22
+ import { discordAuthorRoles } from "../approvals/discord-admin.js";
23
+
24
+ const DISCORD_CHANNEL_DEFAULT_LIMIT = 50;
25
+ const DISCORD_THREAD_DEFAULT_LIMIT = 100;
26
+ const DISCORD_MAX_PAGE = 100;
27
+
28
+ /** A Discord messages GET — mirrors discord-backscroll.ts's live requester but fail-LOUD. Injectable. */
29
+ export type DiscordReadRequester = (channelId: string, limit: number) => Promise<unknown>;
30
+
31
+ const liveDiscordRead: DiscordReadRequester = async (channelId, limit) => {
32
+ const token = process.env.DISCORD_BOT_TOKEN;
33
+ if (!token) throw new Error("read: DISCORD_BOT_TOKEN is not set — the Discord read tools need the bot token.");
34
+ const qs = new URLSearchParams({ limit: String(Math.min(limit, DISCORD_MAX_PAGE)) }).toString();
35
+ const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages?${qs}`, {
36
+ headers: { authorization: `Bot ${token}` },
37
+ });
38
+ if (!res.ok) throw new Error(`read: Discord GET messages failed (HTTP ${res.status}) for channel ${channelId}.`);
39
+ return res.json();
40
+ };
41
+
42
+ /**
43
+ * Map a raw Discord messages array (newest-first) to oldest-first RenderedLine[] — reusing
44
+ * linesFromDiscordMessages for the exact system/empty drop + sender classification. Pure; exported
45
+ * for the render test.
46
+ *
47
+ * `author` IS UNCHANGED (the sender id, else `(unknown)`). OPE-474 adds the two GRADING fields
48
+ * beside it, taken from the sender classification linesFromDiscordMessages already made: a `user`
49
+ * line carries its `authorId`, and a `bot`/`agent` line is marked a machine and drops out of the
50
+ * probe. An `unknown` line carries neither, which is the fail-open answer for an author nobody can
51
+ * name.
52
+ */
53
+ export function discordLinesFromRaw(raw: unknown): RenderedLine[] {
54
+ if (!Array.isArray(raw)) return [];
55
+ const ordered = [...(raw as Array<Record<string, unknown>>)].reverse(); // Discord: newest-first → oldest-first
56
+ return linesFromDiscordMessages(ordered).map((l) => ({
57
+ author: l.senderId ?? "(unknown)",
58
+ ts: l.ts,
59
+ text: l.text,
60
+ ...(l.senderType === "bot" || l.senderType === "agent"
61
+ ? { bot: true }
62
+ : l.senderType === "user" && l.senderId !== undefined
63
+ ? { authorId: l.senderId }
64
+ : {}),
65
+ }));
66
+ }
67
+
68
+ // --- the OPE-474 non-member author leg ---------------------------------------------------------
69
+ //
70
+ // 🔴 DISCORD CANNOT REFUTE AN AUTHOR ITSELF, at all. It has no organisation concept — guild
71
+ // membership IS the boundary, and a community guild admits the world — so the ONLY author-grain
72
+ // evidence here is the operator's author-trust register (identity/author-trust.ts, OPE-516). This is
73
+ // the same conclusion discord-backscroll.ts's `gradeDiscordContextLines` reached for the wake leg,
74
+ // and this adapter deliberately reproduces its shape rather than a second one.
75
+ //
76
+ // `member.pending` — the trigger's own screening signal — is NOT available here either, and the
77
+ // reason is structural rather than an omission: it rides the gateway relay's MESSAGE_CREATE member
78
+ // object, and Discord's documented Message object (what `GET /channels/{id}/messages` returns) has
79
+ // no `member` field at all.
80
+ //
81
+ // THE ROLE PROBE IS COST-GATED THEN FAIL-OPEN, in that order — {@link refutedReadAuthorIds} runs the
82
+ // gate and this supplies the probe:
83
+ // 1. UNARMED guild (the default posture): no register query is worth building, so NO role fetch
84
+ // runs. Zero new I/O.
85
+ // 2. ARMED guild: each DISTINCT author gets ONE TTL-cached {@link discordAuthorRoles} call.
86
+ // 3. `undefined` from the probe means CANNOT CHECK, and the join skips that author entirely —
87
+ // never an unverified `[]`. On a guild armed the documented-usual way
88
+ // (`{ membership: "discord/<g>/role:*" }`) an unverified empty array would make every author
89
+ // look role-less and therefore `external`, which marked ordinary colleagues' messages. That
90
+ // exact bug was caught in OPE-475; the contract that prevents it is in read-author-trust.ts.
91
+
92
+ export const discordReadAdapter = {
93
+ platform: "discord" as const,
94
+ /**
95
+ * OPE-929 — Discord's own spelling of "which room and which thread is this turn in".
96
+ *
97
+ * 🔴 A DISCORD THREAD IS ITS OWN CHANNEL. There is no thread id inside a channel to carry, so the
98
+ * CONTAINER and the THREAD ROOT are the same snowflake on a thread turn, and `readThread` is the
99
+ * verb that reads it (it fetches `threadTs` directly — see below). The turn is a thread turn when
100
+ * the ingress stamped a PARENT channel id, which is exactly what `domainSurface` carries: the
101
+ * mapper sets it only from `parent_channel_id`, so its presence IS "this is a thread".
102
+ *
103
+ * A base-channel turn has no parent stamp, so it reports no thread root and `read_thread` refuses
104
+ * it honestly.
105
+ */
106
+ wakeAddress(ctx: BindableContext): WakeReadAddress | undefined {
107
+ const b = bindFrom(ctx);
108
+ const container = typeof b.target?.["channelId"] === "string" ? (b.target["channelId"] as string) : undefined;
109
+ if (!container) return undefined;
110
+ return { container, ...(b.domainSurface ? { threadRoot: container } : {}) };
111
+ },
112
+ async readChannel(ctx: BindableContext, channel: string, limit?: number, req: DiscordReadRequester = liveDiscordRead): Promise<readonly RenderedLine[]> {
113
+ await assertSurfaceAllowed(ctx, "discord", channel);
114
+ return discordLinesFromRaw(await req(channel, limit ?? DISCORD_CHANNEL_DEFAULT_LIMIT));
115
+ },
116
+ async readThread(ctx: BindableContext, channel: string, threadTs: string, limit?: number, req: DiscordReadRequester = liveDiscordRead): Promise<readonly RenderedLine[]> {
117
+ // A Discord thread is its own channel id — read `thread_ts` (the thread's id), allowlist-checked.
118
+ await assertSurfaceAllowed(ctx, "discord", threadTs);
119
+ return discordLinesFromRaw(await req(threadTs, limit ?? DISCORD_THREAD_DEFAULT_LIMIT));
120
+ },
121
+ /** OPE-474 / OPE-534 — the register leg only (see the note above). It answers with the refuted
122
+ * AUTHOR IDS, because core reads the one answer twice: the turn mark and the `withhold` body
123
+ * replacement. `roleProbe` is injected ONLY so a test can drive the grading offline; every live
124
+ * call defaults to the TTL-cached live probe. */
125
+ async refutedAuthorIds(ctx: BindableContext, lines: readonly RenderedLine[], roleProbe: DiscordRoleProbe = discordAuthorRoles): Promise<readonly string[]> {
126
+ const p = resolvePartition(ctx);
127
+ // `workspace` is the GUILD id for a guild surface, and the DM partner's user id for a DM
128
+ // (discord-identity.ts `bindDiscord`). A DM's key can never be an armed tenant — arming is by
129
+ // guild — so the cost gate answers `false` there without a lookup, which is the right answer:
130
+ // a DM has no guild membership to refute against.
131
+ const tenantKey = p.workspaceKey;
132
+ const guildId = p.workspace;
133
+ return await refutedReadAuthorIds(lines, tenantKey, {
134
+ memberships: async (authorId) => {
135
+ const roles = await roleProbe(guildId, authorId);
136
+ return roles === undefined ? undefined : roles.map((r) => `${tenantKey}/role:${r}`);
137
+ },
138
+ });
139
+ },
140
+ };