@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,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
|
+
};
|