@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,783 @@
1
+ import { type RouteHandlerArgs } from "eve/channels";
2
+ import { type OpenEverywhereDiscordAuth } from "./discord-identity.js";
3
+ import { splitForDiscord } from "./discord-post.js";
4
+ import { type DiscordFetchDeps } from "./discord-rest.js";
5
+ import { type AttachSessionForCancel, contextLineIngestion, type IngestedChatMessage, type ExternalPolicy, type AuditInput, type FinalMessageAction, type NormalizedWake, type WakeEngagementTier, type WakeTrust, type AuthorTrustQuery, type SupervisorGrain, type SingleWindowMentionPolicy, type Partition, type ContextLine, type WakeMessageInput } from "@open-everywhere/core/agent/adapter-api.js";
6
+ export { splitForDiscord };
7
+ import { type ReactionSinkDeps, type ReactionIngestOutcome, type ReactionVerb } from "@open-everywhere/core/agent/adapter-api.js";
8
+ export interface DiscordGatewayState {
9
+ channelId: string;
10
+ guildId?: string;
11
+ originalChannelId?: string;
12
+ originalMessageId?: string;
13
+ }
14
+ export interface DiscordRelayMessage {
15
+ readonly id: string;
16
+ readonly content: string;
17
+ readonly channelId: string;
18
+ /** Discord's numeric channel `type` as a STRING (relay sends the enum number). */
19
+ readonly channelType?: string;
20
+ readonly guildId?: string;
21
+ readonly author: {
22
+ readonly id: string;
23
+ readonly username?: string;
24
+ readonly bot: boolean;
25
+ };
26
+ readonly mentionsBot: boolean;
27
+ readonly referencedMessageId?: string;
28
+ /** The bot's own display name AS SEEN IN THIS GUILD (nickname, else global name) β€”
29
+ * what a human actually types to address it. Sent by relay β‰₯ 5c67655; absent from an
30
+ * older relay, which simply keeps the wake gate mention/engagement-only (fail-closed). */
31
+ readonly botName?: string;
32
+ /** The receiving bot's user id β€” multi-bot attribution (item 27): when N relay clients
33
+ * share one process, each forwards every message, and botId is how dedupe and persona
34
+ * routing tell the copies apart. Same relay payload as botName; optional for old relays. */
35
+ readonly botId?: string;
36
+ /** Whether the message author is a PENDING guild member (membership screening incomplete) β€”
37
+ * the OPE-60 guest/unverified demotion signal. Discord stamps `member.pending` on gateway
38
+ * member objects; forwarded by a relay that includes it (absent from older relays β†’ the
39
+ * binding keeps today's fail-closed/vouch behaviour). Undefined for DMs (no guild member). */
40
+ readonly memberPending?: boolean;
41
+ /**
42
+ * The guild ROLE ids this author holds (Discord stamps `member.roles` on the gateway member
43
+ * object β€” the SAME object `member.pending` rides on, so this costs no extra API call). The input
44
+ * to the author-trust register's membership patterns (OPE-516): a
45
+ * `{ membership: "discord/<guildId>/role:<roleId>" }` declaration matches an author whose roles
46
+ * include that id.
47
+ *
48
+ * Absent for a DM (no guild member) and from an older relay that omits the member object β€” both
49
+ * simply mean no membership evidence, which the register leg treats as "matched nothing".
50
+ */
51
+ readonly memberRoles?: readonly string[];
52
+ }
53
+ /**
54
+ * Defensively parse the relay envelope. Returns null for anything not recognizably a relay
55
+ * MESSAGE_CREATE with the identity facts we MUST have to dispatch + reply β€” a channel id (for
56
+ * the post-back + partition) and an author id (for identity). `content` may legitimately be
57
+ * empty (Message Content Intent disabled surfaces empty strings), so it isn't required here;
58
+ * the route drops empty-content turns separately. NEVER throws.
59
+ */
60
+ export declare function parseRelayPayload(raw: unknown): DiscordRelayMessage | null;
61
+ export interface DiscordRelayReaction {
62
+ readonly verb: ReactionVerb;
63
+ /** The reacted message's id + channel. */
64
+ readonly messageId: string;
65
+ readonly channelId: string;
66
+ readonly guildId?: string;
67
+ readonly channelType?: string;
68
+ /** The emoji: a unicode char (πŸ‘) or a custom emoji NAME. Bucketed by classifyEmoji. */
69
+ readonly emoji: string;
70
+ readonly reactorId: string;
71
+ /** The AUTHOR of the reacted message, when the relay could resolve it β€” drives onBotReply. */
72
+ readonly messageAuthorId?: string;
73
+ /** The receiving bot's own user id (relay-supplied) β€” onBotReply compares against it. */
74
+ readonly botId?: string;
75
+ }
76
+ /**
77
+ * Defensively parse a relay REACTION envelope, or null when it isn't one (wrong source/event,
78
+ * or missing the ids ingestion needs). NEVER throws. Distinguished from a message payload by
79
+ * the `event` discriminator + a `reaction` object (vs `message`).
80
+ */
81
+ export declare function parseRelayReaction(raw: unknown): DiscordRelayReaction | null;
82
+ export interface DiscordRelayHeartbeat {
83
+ /** Identifies the relay PROCESS (its own bot user id) β€” never a guild/channel/user fact. */
84
+ readonly relayId: string;
85
+ /** The relay's own clock, epoch milliseconds, at the moment it sent this beat. */
86
+ readonly ts: number;
87
+ }
88
+ /**
89
+ * Defensively parse a relay HEARTBEAT envelope, or null when it isn't one (wrong source/event, or
90
+ * missing a field). NEVER throws. Distinguished from a message/reaction envelope by the `event`
91
+ * discriminator + a `heartbeat` object (vs `message`/`reaction`) β€” mutually exclusive with both,
92
+ * pinned by scripts/discord-gateway-test.ts.
93
+ */
94
+ export declare function parseRelayHeartbeat(raw: unknown): DiscordRelayHeartbeat | null;
95
+ /** The audit row a heartbeat writes. Pure β€” no I/O β€” so the shape is testable without a live
96
+ * store; the route below is the only caller that actually persists it via `appendAudit`. The
97
+ * action name (`discord.heartbeat`) is what `agent/ops/census.ts`'s `CHANNEL_REPORTED_HEARTBEATS`
98
+ * reads by. `workspace`/`surface` name the RELAY, never a guild β€” this signal is guild-agnostic
99
+ * by design, which is exactly what lets it separate a dead relay from a quiet one. */
100
+ export declare function discordHeartbeatAuditRow(heartbeat: DiscordRelayHeartbeat, org: string): AuditInput;
101
+ /** Dependencies for {@link routeDiscordReaction} β€” injected so parse+route+record is unit-tested
102
+ * without a live store. `attachSession` is eve's cancel door, present ONLY when the route was
103
+ * built with it (the βœ‹ stop arm) β€” absent β‡’ a βœ‹ is ingested as an ordinary reaction. */
104
+ export interface DiscordReactionRouteDeps {
105
+ readonly sink: ReactionSinkDeps;
106
+ readonly attachSession?: AttachSessionForCancel;
107
+ }
108
+ /**
109
+ * Route one parsed Discord reaction into the surface-neutral ingestion seam. The relay already
110
+ * dropped bot reactors, but we double-check against the relayed botId (defence in depth) so the
111
+ * bot's own reaction is never recorded as feedback. onBotReply = the reacted message was authored
112
+ * by the bot; fail-safe when either id is missing (telemetry only, never a false feedback row).
113
+ */
114
+ export declare function routeDiscordReaction(rx: DiscordRelayReaction, deps: DiscordReactionRouteDeps): Promise<ReactionIngestOutcome>;
115
+ export interface RelayRejection {
116
+ readonly status: number;
117
+ readonly reason: string;
118
+ }
119
+ /**
120
+ * Gate one relay delivery. Returns null when it may proceed, else the {@link RelayRejection}.
121
+ * `secret` defaults to the live env read but is injectable so the gate is unit-tested without
122
+ * touching process.env. Fail-closed: unset/empty secret β†’ 401; missing/malformed/mismatched
123
+ * signature β†’ 401 (verifyWebhookSignature returns false for a null or non-`sha256=` header).
124
+ */
125
+ export declare function relayGate(signatureHeader: string | null, rawBody: string, secret?: string | undefined): RelayRejection | null;
126
+ /**
127
+ * Reconstruct the dispatch auth from a relay message, or null to DROP. Reuses
128
+ * buildDiscordAuth verbatim (channels/discord.ts uses the same for the interaction path) so
129
+ * the identity seam (identity/binding.ts bindDiscord) resolves the real guild/channel/user β†’
130
+ * workspace/surface/mode/visibility. Drops bot authors: we never act on another bot (incl.
131
+ * ourselves) β€” mirrors discord.ts's authFromInteraction and the relay's own bot filter.
132
+ */
133
+ export declare function authForRelayMessage(msg: DiscordRelayMessage): OpenEverywhereDiscordAuth | null;
134
+ /**
135
+ * Is this bot-authored relay message's author id a KNOWN e2e-driver id (OPE-647) β€” pinned to
136
+ * SOME guild in `OE_TEST_DRIVER_IDS` β€” but NOT to this message's own guild? `authForRelayMessage`
137
+ * already refuses the message either way: `isAllowedDriver` is guild-pinned by format
138
+ * (`guildId:botUserId`), so a driver id posting outside its pinned guild never passes. This
139
+ * classifier exists only to tell that refusal APART from routine "some unrelated real bot
140
+ * posted" noise, so a driver credential surfacing in the wrong guild gets a NAMED audit row
141
+ * instead of vanishing into the ordinary bot-drop silence. Pure β€” no I/O β€” so every branch is
142
+ * unit-tested against a hand-built allowlist, with no live Discord and no store.
143
+ */
144
+ export declare function isMisplacedDriver(guildId: string | undefined, botUserId: string, allowlist?: ReadonlySet<string>): boolean;
145
+ /**
146
+ * The AUTHOR-TRUST question for this message (OPE-516) β€” the operator's own declaration of who is
147
+ * internal in this guild, which is the ONLY author-grain evidence Discord can produce. Discord has
148
+ * no organisation concept, so `discordTrust` can never refute an author; `member.pending` is a
149
+ * SCREENING verdict, not a membership one. The register fills that hole from reviewed config.
150
+ *
151
+ * Returns `{}` for a DM: a DM has no guild, so there is no tenant to arm and no roles to hold. The
152
+ * DM's trust question is about the PARTNER and is answered by the surface register instead β€” the
153
+ * same split `ChatWakeRoom.dm` makes on the room leg. A spread-able fragment rather than a
154
+ * `| undefined` value so the call sites stay one expression and an absent author is byte-identical
155
+ * to before this existed.
156
+ *
157
+ * The tenant key is built from the guild id and NOTHING ELSE. The platform and tenant halves of a
158
+ * principal key are surface facts, never properties of the id (identity/principal-key.ts) β€” and a
159
+ * snowflake is a bare digit string that would collide happily across vendors if it were sniffed.
160
+ */
161
+ export declare function authorFacts(msg: DiscordRelayMessage): {
162
+ readonly authorTrust?: AuthorTrustQuery;
163
+ };
164
+ /**
165
+ * THE EXTERNAL-PARTICIPANT VERDICT for one Discord message (OPE-507 / OPE-504) β€” pure, so the four
166
+ * outcomes are tested without a REST call, a store or a live relay. `dispatchTurn` calls it once and
167
+ * switches on the answer.
168
+ *
169
+ * The four values name WHAT THE CALLER OWES, which is the only thing that differs between them:
170
+ * `proceed` β€” nothing suppressed. The shipped grains always return this.
171
+ * `withheld-addressed` β€” an excluded author @mentioned or DM'd. Owed a PRIVATE notice, nothing else.
172
+ * `withheld-ambient` β€” an excluded author spoke ambiently. Owed NOTHING; suppress in silence.
173
+ * `silent-room` β€” the room is classified external and the operator asked for silence. Owed
174
+ * the once-ever suppressed-wake announcement.
175
+ *
176
+ * πŸ”΄ THE AUTHOR GRAIN IS TESTED FIRST, AND THE ORDER MATTERS. An excluded author in a silenced room
177
+ * must get the AUTHOR outcome: they are owed a notice they can act on ("ask someone inside"), where
178
+ * the room announcement tells the whole room something about the room. Reversing this would answer
179
+ * the wrong question to the wrong audience.
180
+ */
181
+ export type DiscordExternalVerdict = "proceed" | "withheld-addressed" | "withheld-ambient" | "silent-room";
182
+ export declare function discordExternalVerdict(input: {
183
+ readonly policy: ExternalPolicy;
184
+ readonly author: IngestedChatMessage;
185
+ /** The message @mentions the bot, or arrived in a DM β€” an ADDRESSED message is owed an answer. */
186
+ readonly addressed: boolean;
187
+ readonly isDm: boolean;
188
+ /** The DOMAIN-grain surface key (a thread asks its parent channel), for the room classification. */
189
+ readonly roomKey: string;
190
+ }): DiscordExternalVerdict;
191
+ /**
192
+ * Is this channel type a THREAD? Discord 10 (announcement thread) / 11 (public thread) /
193
+ * 12 (private thread) β€” the surfaces whose channel id is NOT the id domains and spend
194
+ * caps are declared against (those name the PARENT channel). Pure; drives the
195
+ * parent-grain stamping in dispatchTurn (item 22).
196
+ */
197
+ export declare function isThreadChannelType(channelType: string | undefined): boolean;
198
+ /**
199
+ * Is this message a direct message to the bot? Discord channel type 1 (DM) / 3 (group DM).
200
+ * A DM is BY DEFINITION addressed to the bot β€” there is no one else it could be for β€” so it
201
+ * wakes without a mention and its text is a direct instruction (no judged preamble), exactly
202
+ * like a Slack DM. Pure.
203
+ */
204
+ export declare function isDmChannel(channelType: string | undefined): boolean;
205
+ /**
206
+ * Should this ambient message wake the agent? A DM always wakes (it's inherently addressed to
207
+ * the bot). An @mention is a direct address (guaranteed reply, like a Slack mention); otherwise
208
+ * the channel must already be ENGAGED β€” the agent was pulled in earlier and keeps following
209
+ * without re-tagging (engagement.ts, the same registry Slack uses). A non-mention message in a
210
+ * non-engaged guild channel is dropped, so the bot stays quiet in channels it wasn't invited
211
+ * into. Pure β†’ unit-tested without the operational store.
212
+ */
213
+ export declare function shouldDispatchDiscord(input: {
214
+ readonly mentionsBot: boolean;
215
+ readonly isEngaged: boolean;
216
+ readonly isDm: boolean;
217
+ }): boolean;
218
+ /**
219
+ * Is this message a NAME-DROP candidate β€” Slack's nameDropWake brought to Discord (item 25;
220
+ * live 2026-07-06 Tommy addressed the bot by display name and it only "worked" where the
221
+ * channel happened to be engaged)? Pure pre-filter, cheapest checks first; the caller still
222
+ * gates on the domain's `nameDropWake` flag (config, evaluated at DOMAIN grain β€” a thread
223
+ * checks its parent channel's domain). Disqualifiers:
224
+ * - mention / DM / engaged β€” those wake paths own the message already;
225
+ * - a bot author β€” never self/bot-trigger;
226
+ * - anything but a guild conversation surface (text 0, announcement 5, threads 10/11/12).
227
+ * DELIBERATE Slack DEVIATION: Slack excludes thread replies ("the engagement path owns
228
+ * threads") but on Discord threads ARE the primary conversation surface and an
229
+ * UN-engaged thread (TTL'd out, or human-created) that says the bot's name is a clear
230
+ * address with no other wake path β€” the exact live gap. Forum containers (15) and
231
+ * unknown types stay excluded, fail-safe;
232
+ * - no relayed bot name (older relay) β€” fail-closed to mention/engagement-only, so
233
+ * `identity.name`/`wake.nameDrop.include` alone never fires this without a live name too.
234
+ * Unlike Slack there is NO externally-shared-channel probe: Discord has no shared-channel
235
+ * concept β€” guild membership IS the boundary, so a guild surface is internal by construction.
236
+ * The text is matched against the UNIFIED wake token set (OPE-571): `input.botName` (the live
237
+ * Discord display name) UNION `identity.name`, extended by `wake.nameDrop.include` and narrowed
238
+ * by `wake.nameDrop.exclude` β€” see {@link wakeNameDropTokens}, the same builder Slack's
239
+ * `buildWakeTriggers` uses, so the two surfaces' wake set stays one rule.
240
+ */
241
+ export declare function nameDropEligible(input: {
242
+ readonly mentionsBot: boolean;
243
+ readonly isDm: boolean;
244
+ readonly isEngaged: boolean;
245
+ readonly authorIsBot: boolean;
246
+ readonly channelType?: string;
247
+ readonly content: string;
248
+ readonly botName?: string;
249
+ }): boolean;
250
+ /**
251
+ * Low-level create-message: `POST /channels/{id}/messages` with the bot token. Returns the NEW
252
+ * message's id on success (needed by the holding-message flow so completion can edit it), or null
253
+ * on any failure β€” no token, non-ok status, network error, or a response missing an id. NEVER
254
+ * throws. postDiscordReply + postHoldingMessage are both built on this so the auth/error handling
255
+ * lives in one place (the bot token is a SECRET β€” never logged).
256
+ */
257
+ export declare function discordCreateMessage(channelId: string, content: string, deps?: DiscordFetchDeps): Promise<string | null>;
258
+ /**
259
+ * Post the agent's reply to a Discord channel via REST. Best-effort per chunk: a failed chunk is
260
+ * logged (with status, never the token) and the rest still attempt. No DISCORD_BOT_TOKEN β†’ each
261
+ * chunk logs and no-ops (the turn already ran; silence is the only degradation). This is the plain
262
+ * post-back used when status UX is off, and the fail-open fallback when the holding-message flow
263
+ * couldn't run.
264
+ */
265
+ /**
266
+ * Open (or re-open) the 1:1 DM channel with one user and return its id β€” `POST /users/@me/channels`.
267
+ * Idempotent on Discord's side: it returns the EXISTING DM channel when one is already open, so
268
+ * calling it repeatedly creates nothing.
269
+ *
270
+ * It exists for ONE caller: the external-sender notice (OPE-507 behaviour 1), which owes the sender a
271
+ * PRIVATE message. Fail-soft to `null` β€” see {@link postExternalSenderNotice} for why a failure here
272
+ * must stay silent rather than fall back to the channel.
273
+ */
274
+ export declare function openDiscordDm(userId: string, deps?: DiscordFetchDeps): Promise<string | null>;
275
+ /** Test hook: drop every recorded throttle timestamp. Production correctness needs this
276
+ * map to live for the whole process (the throttle is meant to span turns), but the
277
+ * golden-transcript scenario harness (discord-scenario-harness.ts) runs many scenarios in ONE
278
+ * process and reuses small deterministic channel/thread ids ("TH1", …) across them β€” so
279
+ * without a reset, whether a later scenario's typing throttle "holds" depends on the
280
+ * REAL wall-clock gap since an earlier, unrelated scenario last wrote the same key: fast
281
+ * test execution (<7s between scenarios) silently suppresses a typing re-trigger the
282
+ * golden doesn't expect to be suppressed; a slow/loaded run (>7s) doesn't, and the extra
283
+ * POST diverges the golden β€” a genuine flake pinned as OPE-69 (confirmed by forcing an
284
+ * 8s real delay between scenarios 01/02, which flipped the throttle and diverged
285
+ * 02-thread-followup-resume). Called by ScenarioRunner.scenario() before every scenario
286
+ * so each starts cold, exactly like clearDiscordInfoCacheForTest(). */
287
+ export declare function clearTypingThrottleForTest(): void;
288
+ /** True when a typing re-trigger is due for this channel (and records it as fired). Pure
289
+ * given an injected clock β†’ unit-tested; the default clock is Date.now. */
290
+ export declare function typingRefreshDue(channelId: string, nowMs?: number, state?: Map<string, number>): boolean;
291
+ /** Close the gate for a judged wake: hold all typing until the reply is proven. */
292
+ export declare function closeTypingGate(channelId: string, gates?: Set<string>): void;
293
+ /** Open (clear) the gate β€” addressed dispatch, reply proven, or turn over. */
294
+ export declare function openTypingGate(channelId: string, gates?: Set<string>): void;
295
+ export declare function typingGateOpen(channelId: string, gates?: Set<string>): boolean;
296
+ /**
297
+ * Feed the streamed assistant text into the gate; returns whether typing may fire. Mirrors
298
+ * slack-events.ts isNoReply EXACTLY (trimStart + case-sensitive startsWith): while the text
299
+ * is still a prefix of the sentinel the verdict is UNDECIDED (hold); once it starts with the
300
+ * full sentinel it IS a judged silence (hold β€” the final reply will be swallowed); any
301
+ * deviation proves a real reply, so the gate opens and stays open for the rest of the turn.
302
+ */
303
+ export declare function typingGateFeed(channelId: string, messageSoFar: string, gates?: Set<string>): boolean;
304
+ export declare function triggerDiscordTyping(channelId: string, deps?: DiscordFetchDeps): Promise<void>;
305
+ /**
306
+ * Add or remove the bot's own reaction on a message: `{PUT|DELETE}
307
+ * /channels/{id}/messages/{messageId}/reactions/{emoji}/@me` with the emoji URL-encoded. Used to
308
+ * show πŸ‘€ the moment we decide to respond and swap to βœ… (or just clear πŸ‘€) on completion. Purely a
309
+ * status affordance β€” best-effort, any failure is logged and swallowed, NEVER throws, and a missing
310
+ * token no-ops. (The bot token is a SECRET β€” never logged.)
311
+ */
312
+ export declare function setDiscordReaction(channelId: string, messageId: string, emoji: string, action: "PUT" | "DELETE", deps?: DiscordFetchDeps): Promise<void>;
313
+ /** Strip ONE leading bot-mention token (`<@123>` / `<@!123>`) β€” shared by thread naming and
314
+ * the stop-verb match, both of which care about the words AFTER the address. Pure. */
315
+ export declare function stripLeadingMention(content: string): string;
316
+ /**
317
+ * Should a mention here open a new thread? True ONLY for a base guild text (0) or announcement
318
+ * (5) channel when the toggle is on. False for threads (10/11/12 β€” already a thread surface),
319
+ * DMs / group DMs (1/3 β€” no threads), forum channels (15 β€” a forum post is itself a thread;
320
+ * don't nest), and any unknown type (fail-safe: keep today's inline behaviour). Pure.
321
+ */
322
+ export declare function shouldAutoThread(channelType: string | undefined, enabled: boolean): boolean;
323
+ /**
324
+ * Read the discord.autoThread toggle. Default ON β€” auto-thread is the on-brand behaviour;
325
+ * a deployer opts OUT of it. Only the explicit "false"/"0" disable; anything else (unset,
326
+ * "true", "1", …) leaves it on. `env` is injectable so the toggle is unit-tested without
327
+ * touching process.env. Pure.
328
+ */
329
+ export declare function autoThreadEnabled(env?: string): boolean;
330
+ /**
331
+ * Read the discord.statusUx toggle. Default ON β€” the "working" affordances (πŸ‘€/βœ… reaction + a
332
+ * holding message edited into the reply) are on-brand, so a deployer opts OUT. Only the explicit
333
+ * "false"/"0" disable; anything else (unset, "true", "1", …) leaves it on. When off, the surface
334
+ * behaves EXACTLY as before this feature: one plain reply post, no reaction, no holding message.
335
+ * `env` is injectable so the toggle is unit-tested without touching process.env. Pure. Mirrors
336
+ * autoThreadEnabled verbatim.
337
+ */
338
+ export declare function statusUxEnabled(env?: string): boolean;
339
+ /**
340
+ * Derive a Discord thread name from the triggering message. Strips a single leading bot
341
+ * mention token (`<@123>` or `<@!123>`), collapses whitespace, and truncates to Discord's
342
+ * 100-char cap (trimming back to a word boundary when one sits reasonably near the cut).
343
+ * Falls back to `Conversation with <botName>` when nothing usable remains (e.g. a bare
344
+ * mention with no text). Pure + deterministic β€” no model call (v1; a cheap-model title is a
345
+ * noted later enhancement).
346
+ */
347
+ export declare function deriveThreadName(content: string, botName?: string): string;
348
+ /**
349
+ * Create a Discord thread hanging off the triggering message:
350
+ * `POST /channels/{channelId}/messages/{messageId}/threads` with the bot token. Returns the
351
+ * new thread's channel id on success, or null on ANY failure (no token, non-ok status,
352
+ * network error, or a response missing an id). NEVER throws β€” threading is fail-OPEN: a null
353
+ * here degrades the turn to an inline reply, it never drops the turn. Mirrors postDiscordReply's
354
+ * auth + error handling exactly (including: the bot token is a SECRET β€” never logged).
355
+ */
356
+ export declare function createDiscordThread(channelId: string, messageId: string, name: string, deps?: DiscordFetchDeps): Promise<string | null>;
357
+ /**
358
+ * Compute the dispatch surface for a turn, given the thread-create outcome. This is the
359
+ * RE-KEY: when a thread was created (threadId non-null), the ENTIRE turn moves onto the thread
360
+ * channel id β€” identity/auth, continuation token, reply-target state, and engagement key all
361
+ * point at the thread, so the conversation lives there (its own memory silo + durable session)
362
+ * and the base channel is only where the mention landed. When no thread was created (threadId
363
+ * null β€” either not auto-threadable or a fail-open fallback), the turn stays on the base
364
+ * channel exactly as today. Pure β†’ the fail-open contract is unit-tested by passing an id / null
365
+ * without any live Discord call.
366
+ */
367
+ export declare function dispatchSurfaceFor(msg: DiscordRelayMessage, baseAuth: OpenEverywhereDiscordAuth, threadId: string | null): {
368
+ auth: OpenEverywhereDiscordAuth;
369
+ continuationToken: string;
370
+ state: DiscordGatewayState;
371
+ engagementKey: string;
372
+ };
373
+ /** How to reconcile the πŸ‘€ reaction on completion (auto-threaded mentions only). */
374
+ export type ReactionResolution = "clear" | "none";
375
+ /** How to deliver the final reply. Always a FRESH post β€” never an edit (no "(edited)" tag). */
376
+ export type CompletionDelivery = {
377
+ readonly kind: "post";
378
+ readonly chunks: readonly string[];
379
+ } | {
380
+ readonly kind: "none";
381
+ };
382
+ export interface CompletionPlan {
383
+ readonly reaction: ReactionResolution;
384
+ readonly delivery: CompletionDelivery;
385
+ }
386
+ /**
387
+ * Translate the shared finalMessageAction decision into the concrete completion operations. Pure β€”
388
+ * no I/O, no env, fully unit-tested.
389
+ * - post β†’ POST the reply fresh (split to Discord's 2000-char cap); swap πŸ‘€β†’βœ… when this turn
390
+ * placed a swap-tracked reaction (auto-threaded mention).
391
+ * - silent β†’ post nothing; clear the πŸ‘€ if one was swap-tracked (don't leave "seen, working"
392
+ * standing on a mention the agent decided not to answer).
393
+ * - buffer-tool-status β†’ interim tool turn, not the final reply β†’ do nothing at all.
394
+ * `reactionActive` = this turn seeded original-message coords (status UX on + auto-threaded).
395
+ * The non-threaded πŸ‘€ (DMs, in-thread mentions) is a plain "seen" ack, deliberately NOT swapped
396
+ * or cleared β€” the answer lands right under the message, so the ack can simply stand.
397
+ */
398
+ export declare function planCompletion(input: {
399
+ readonly action: FinalMessageAction;
400
+ readonly reactionActive: boolean;
401
+ /** An approval card was posted this turn (approvals/card-signal.ts) β€” the card IS the
402
+ * reply, so the model's narration is suppressed (same rule as Slack: "the card should
403
+ * suffice"). The turn still counts as answered β†’ the πŸ‘€ still clears. */
404
+ readonly cardPostedThisTurn?: boolean;
405
+ }): CompletionPlan;
406
+ /**
407
+ * Assemble the session state SEEDED into `send` for a turn. This seed only takes effect when
408
+ * the send STARTS a session (eve drops it on the resume path β€” see the session-state comment
409
+ * up top), which is exactly why `original` is only ever passed on the auto-thread turn: a fresh
410
+ * thread id guarantees a fresh session, so the seeded reaction coords are guaranteed to belong
411
+ * to THIS turn's triggering message. The reply surface (`channelId`) follows the re-key (the
412
+ * thread when threaded); the reaction target stays pinned to the mention in the BASE channel so
413
+ * πŸ‘€/βœ… land where the human is looking. No original coords β†’ completion does no reaction swap.
414
+ */
415
+ export declare function buildGatewayState(input: {
416
+ readonly replyChannelId: string;
417
+ /** The guild the reply surface is in β€” seeded so the completion handler can rebuild the
418
+ * engagement partition (see DiscordGatewayState.guildId). Absent for DMs (no guild). */
419
+ readonly guildId?: string;
420
+ readonly original?: {
421
+ readonly channelId: string;
422
+ readonly messageId: string;
423
+ };
424
+ }): DiscordGatewayState;
425
+ /**
426
+ * Consume this turn's reaction-swap coordinates from the LIVE session state: return the
427
+ * original-message coords and CLEAR them (consume-once). The clearing is the correctness
428
+ * guarantee on resumed sessions β€” a later turn can never react on a PREVIOUS turn's message;
429
+ * the coords only ever exist on the session-creating auto-thread turn. Mutates the passed
430
+ * state in place (eve write-back persists it). Pure in/out mapping β†’ unit-tested.
431
+ */
432
+ export declare function takeTurnStatus(state: DiscordGatewayState): {
433
+ original?: {
434
+ channelId: string;
435
+ messageId: string;
436
+ };
437
+ };
438
+ /** The cross-channel dispatch primitive the route hands the dispatch (eve's public
439
+ * `args.to` β€” the same primitive github.ts's fan-out + divert legs use; it was `args.receive`
440
+ * before eve 0.31 split the call into `to(channel, target).send(message, options)`). */
441
+ export type UnifiedReceive = RouteHandlerArgs["to"];
442
+ /**
443
+ * THE DISCORD PRIVACY FLOOR (fail-closed): may this surface join a PRIVATE cross-surface window?
444
+ *
445
+ * Every single-window surface needs a POSITIVE privacy fact before its wakes may share a context
446
+ * window with the domain's private Slack/GitHub/Linear/Notion conversations (GitHub:
447
+ * `repository.private === true`; Slack: the channel positively confirmed internal; Notion: the
448
+ * operator-vouch register). Discord's honest answer is that NO reliable positive signal exists
449
+ * in what reaches this gateway:
450
+ * - The guild is the tenant boundary (external people aren't guild members), but guilds are
451
+ * routinely PUBLIC/community servers with open invites β€” guild membership alone proves
452
+ * nothing about who can read or post.
453
+ * - The relay's MESSAGE_CREATE payload carries NO channel-permission data. The identity
454
+ * layer's `everyoneCanView` field (discord-identity.ts) is documented "absent today" β€” and
455
+ * its `discordVisibility` default of `private` is the WRONG direction here: that default is
456
+ * conservative for MEMORY (learnings stay surface-tier), but for window ADMISSION
457
+ * "default-private" would be permissive β€” it would admit every guild channel, including
458
+ * @everyone-viewable channels in public community guilds. A default is not a positive fact.
459
+ * So, exactly like Notion (whose public_url was similarly misleading), the floor is the
460
+ * OPERATOR-VOUCH register (identity/trust.ts isVouchedInternal β€” the deployer's out-of-band
461
+ * "this guild/channel is members-only internal" assertion, keyed on the workspaceKey
462
+ * `discord/<guild>` or a surfaceKey `discord/<guild>/<channel>`; a thread also honours a vouch
463
+ * on its PARENT channel via the domain-grain key), or the OE_TRUST=internal blanket switch for
464
+ * internal-only deploys. Code enforces, the operator vouches β€” never an inference from a payload
465
+ * field. Fail-CLOSED: un-vouched β‡’ false β‡’ the wake stays on the native per-channel path.
466
+ *
467
+ * DMs / group-DMs (no guildId) NEVER pass β€” unconditionally, even under OE_TRUST=internal
468
+ * (mirroring how the e2e driver allowlist excludes guildless surfaces): a DM is a PERSONAL
469
+ * surface with no tenant boundary at all, and its content must never enter a team-shared window.
470
+ *
471
+ * ATTACKER ANALYSIS (what goes wrong if this rule is too loose): if a public/@everyone-readable
472
+ * channel were admitted, anyone who can join the guild (open invite) could (a) READ private
473
+ * cross-surface context β€” an @mention here would recall, into a public channel, whatever the
474
+ * shared window absorbed from the domain's private repos/channels/docs β€” and (b) WRITE into the
475
+ * shared context: their message text enters the one window every surface's supervisor judges
476
+ * from, a persistent cross-surface prompt-injection foothold. The vouch register makes admission
477
+ * an explicit operator assertion with exactly that blast radius in view.
478
+ *
479
+ * Pure (vouch + env injectable) β†’ the truth table is unit-tested without a live register
480
+ * (scripts/unified-window-test.ts). Mirrors notion.ts's floor + github.ts's positive-privacy gate.
481
+ */
482
+ export declare function discordSingleWindowPrivacyOk(input: {
483
+ readonly guildId: string | undefined;
484
+ /** `discord/<guild>` β€” a workspace-level vouch covers every channel in the guild. */
485
+ readonly workspaceKey: string;
486
+ /** `discord/<guild>/<channel-or-thread>` β€” a surface-level vouch narrows to one. */
487
+ readonly surfaceKey: string;
488
+ /** The DOMAIN-grain key (a thread's parent channel β€” domainKeyOf); a vouch on the parent
489
+ * channel covers its threads. Equal to surfaceKey for a non-thread. */
490
+ readonly domainKey?: string;
491
+ }, deps?: {
492
+ readonly vouched?: (workspaceKey: string, surfaceKey: string) => boolean;
493
+ readonly trustEnv?: string | undefined;
494
+ }): boolean;
495
+ /**
496
+ * The Discord arm of the ONE single-window mention decision β€” a thin shim over the shared seam
497
+ * (unified-window.ts {@link singleWindowMentionPolicy}), exactly like github.ts's
498
+ * shouldSuppressNativeMention and slack-events.ts's slackSingleWindowMentionPolicy:
499
+ * `suppressNative` ⟺ the divert owns the @mention (this file's native dispatch β€” auto-thread,
500
+ * πŸ‘€, typing, per-channel session β€” stands down); `addressing` is the `direct-mention` stamp the
501
+ * diverted wake carries so the unified supervisor knows it is owed an answer β€” which it must give
502
+ * through the `reply` tool, since OPE-1982 deleted the prose route. Both derive from the SAME predicate (`singleWindow && privacyOk && isMention`), so
503
+ * suppress-and-keep stay perfectly complementary: never a double reply, never a gap. `privacyOk`
504
+ * is {@link discordSingleWindowPrivacyOk}'s verdict (fail-closed). Pure β†’ unit-tested truth table.
505
+ */
506
+ export declare function discordSingleWindowMentionPolicy(singleWindow: boolean, isMention: boolean, privacyOk: boolean): SingleWindowMentionPolicy;
507
+ /** Everything {@link buildDiscordUnifiedWake} needs β€” extracted by the divert wiring, built
508
+ * directly by tests. */
509
+ export interface DiscordUnifiedWakeInput {
510
+ readonly guildId: string;
511
+ /** The conversation the trigger arrived in (a thread's OWN channel id when in a thread). */
512
+ readonly channelId: string;
513
+ /** The thread's parent channel, when known (auth enrichment) β€” the locus CONTAINER. */
514
+ readonly parentChannelId?: string;
515
+ readonly isThread: boolean;
516
+ readonly addressing: "direct-mention" | "channel-watch" | "name-drop";
517
+ /** This conversation carries an ENGAGED registry row β€” an @mention (or an earned reply) invited
518
+ * the agent and the invitation still stands β€” so a JUDGED wake states `follow-up="thread-engaged"`
519
+ * (channels/engaged-wake.ts). Unset/false β‡’ no attribute, byte-identical. */
520
+ readonly threadEngaged?: boolean;
521
+ readonly wakeId: string;
522
+ readonly engagementTier?: WakeEngagementTier;
523
+ /** The OUTGOING generation's recorded TAIL (OPE-794), rendered as this first wake's backscroll.
524
+ * Only a FRESH generation's first wake carries one. */
525
+ readonly tail?: readonly WakeMessageInput[];
526
+ /** The hydrated surrounding conversation, STRUCTURED β€” `renderWake` renders it through core's own
527
+ * road, which de-duplicates it against {@link tail} and caps BELOW that (OPE-1749). This road used
528
+ * to hand a pre-rendered string down beside the wake, which bypassed both. */
529
+ readonly contextLines?: readonly ContextLine[];
530
+ /** `renderContext`'s provenance label for {@link contextLines}. */
531
+ readonly contextSource?: string;
532
+ /** The bot's display name β€” name-drop wakes only (what the human actually said). */
533
+ readonly botName?: string;
534
+ /** This surface could not read its own history since the seat's last wake (core
535
+ * channels/seat-catch-up.ts). Rendered as a trusted `<system-note>`; unset β‡’ no note. */
536
+ readonly catchUpUnavailable?: string;
537
+ readonly trigger: {
538
+ readonly id: string;
539
+ readonly author: string;
540
+ readonly authorId: string;
541
+ readonly sentAt: string;
542
+ readonly text: string;
543
+ /**
544
+ * Resolved per-wake trust tier (identity/wake-trust.ts).
545
+ *
546
+ * πŸ”΄ REQUIRED, NOT OPTIONAL (OPE-596) β€” see LinearSupervisorWakeInput.trigger.trust. The field
547
+ * used to default UPWARD (`?? (direct ? "principal" : "peer")`), which is fail-OPEN, and since
548
+ * OPE-577 the tier decides the `<untrusted_external_data>` fence. A caller that cannot resolve
549
+ * a tier states the fail-closed one itself: `relay` for a machine author, `unauth` for an
550
+ * unvouchable human.
551
+ */
552
+ readonly trust: WakeTrust;
553
+ };
554
+ }
555
+ /**
556
+ * Build the NormalizedWake for one DIVERTED Discord wake β€” the exact shape
557
+ * buildGitHubSupervisorWake produces for GitHub, with Discord's locus mapping: container =
558
+ * the (parent) channel, conversation = the thread when the trigger was in one. Always stamps
559
+ * the platform address + P5 disclosure + wake-id (this builder exists ONLY for the
560
+ * single-window path β€” the native dispatch renders via renderTurn/renderNameDropTurn,
561
+ * untouched). renderWake then emits the `{message, context}` every OE dispatch sends β€” the
562
+ * SAME renderer Slack/GitHub/Linear/Notion use, with NO platform branch. Pure β†’ unit-testable.
563
+ */
564
+ export declare function buildDiscordUnifiedWake(input: DiscordUnifiedWakeInput): NormalizedWake;
565
+ /** Everything {@link discordNativeWake} needs β€” extracted by the native dispatch, built directly
566
+ * by tests. The NON-unified sibling of {@link DiscordUnifiedWakeInput}: no wake-id (per-channel
567
+ * session; the reply posts back via message.completed, not a routed reply tool) and no P5
568
+ * disclosure (single-surface, not a cross-surface window). */
569
+ export interface DiscordNativeWakeInput {
570
+ /** The conversation the trigger arrived in (a thread's OWN channel id when in a thread). */
571
+ readonly channelId: string;
572
+ /** The thread's parent channel, when known β€” the locus CONTAINER (domain/spend grain). */
573
+ readonly parentChannelId?: string;
574
+ readonly isThread: boolean;
575
+ /** A DM (channel type 1/3) β€” an inherently-addressed `direct` wake whose reason is `dm`. */
576
+ readonly isDm: boolean;
577
+ readonly addressing: "direct" | "thread-follow" | "name-drop";
578
+ /** This conversation carries a PARTICIPATING, un-stopped engagement row β€” see
579
+ * {@link DiscordUnifiedWakeInput.threadEngaged}. πŸ”΄ NOT derivable from `thread-follow`: that tier
580
+ * is reached past an `isEngaged` gate a name-drop's WARM latch (`participating: false`) also
581
+ * satisfies, and a seat nobody invited must not be told it is enrolled. */
582
+ readonly threadEngaged?: boolean;
583
+ /** The bot's display name β€” name-drop wakes only (what the human actually said). */
584
+ readonly botName?: string;
585
+ /** RAW side-channel backscroll (mention/name-drop hydration) β€” renderWake wraps it (W3). */
586
+ readonly contextLines?: readonly ContextLine[];
587
+ readonly contextSource?: string;
588
+ readonly trigger: {
589
+ readonly id: string;
590
+ readonly author: string;
591
+ readonly authorId: string;
592
+ readonly sentAt: string;
593
+ readonly text: string;
594
+ /** Resolved per-wake trust tier (identity/wake-trust.ts). πŸ”΄ REQUIRED (OPE-596) β€” see
595
+ * {@link DiscordUnifiedWakeInput}'s trigger tier for the whole reason. */
596
+ readonly trust: WakeTrust;
597
+ };
598
+ }
599
+ /**
600
+ * Build the NormalizedWake for one NATIVE (non-diverted) Discord wake β€” the per-channel-session
601
+ * path that renderTurn/renderNameDropTurn used to hand-render. It maps into the SAME shared type
602
+ * every other surface uses, so renderWake emits the one canonical dialect with NO platform branch:
603
+ * - `direct` (DM / @mention): the conversation driver β€” renderWake returns the body VERBATIM (no
604
+ * preamble, no envelope, unwrapped β€” W4), byte-identical to the old renderTurn(true).
605
+ * - `thread-follow` (engaged non-mention) / `name-drop`: JUDGED β€” the canonical Slack-parity
606
+ * preamble + the full wake envelope (the migration's deliberate convergence; the old Discord
607
+ * prose stub had DRIFTED away from the social-chatter / never-re-answer / PREFER_NO_REPLY_TOOL
608
+ * sentences, each of which encodes an observed judge failure).
609
+ * Locus: container = the (parent) channel; conversation = the thread when the trigger was in one.
610
+ * No wakeId is minted (a native wake answers through the base `reply`, which names no wake). Pure β†’
611
+ * unit-testable without a live Discord call.
612
+ */
613
+ export declare function discordNativeWake(input: DiscordNativeWakeInput): NormalizedWake;
614
+ /** Everything {@link buildDiscordDmWake} needs β€” extracted by the DM divert, built directly by
615
+ * tests. Deliberately NARROWER than {@link DiscordUnifiedWakeInput}: a DM has no guild, no thread,
616
+ * no engagement tier and no name-drop, and every one of those absences is a property of the
617
+ * surface rather than a field a caller might or might not fill in. */
618
+ export interface DiscordDmWakeInput {
619
+ /** The DM (or group-DM) channel id β€” a DM's whole locus. */
620
+ readonly channelId: string;
621
+ readonly wakeId: string;
622
+ /** The OUTGOING generation's recorded TAIL (OPE-794), rendered as this first wake's backscroll.
623
+ * Only a FRESH generation's first wake carries one. */
624
+ readonly tail?: readonly WakeMessageInput[];
625
+ readonly trigger: {
626
+ readonly id: string;
627
+ readonly author: string;
628
+ readonly authorId: string;
629
+ readonly sentAt: string;
630
+ readonly text: string;
631
+ /** Resolved per-wake trust tier (identity/wake-trust.ts). πŸ”΄ REQUIRED (OPE-596) β€” the field
632
+ * used to default to `principal`, the highest tier, on the reasoning that a DM partner IS the
633
+ * seat's principal. That reasoning is about the ROOM, and the tier is about the AUTHOR. See
634
+ * {@link DiscordUnifiedWakeInput}'s trigger tier. */
635
+ readonly trust: WakeTrust;
636
+ };
637
+ }
638
+ /**
639
+ * THE 1:1 (and group) DM WAKE for a first-classed Discord DM window (OPE-356) β€” the Discord twin of
640
+ * slack-events.ts's `slackDmWake`, and it renders through the SAME core renderer with no platform
641
+ * branch, so the two surfaces' DM envelopes are one dialect.
642
+ *
643
+ * ADDRESSING IS `direct-mention`, ALWAYS, doing the same two jobs it does on Slack. Semantically it
644
+ * is simply TRUE: everything said in a DM is said to the agent, which is why this path bypasses the
645
+ * engagement gate, the name-drop judge and the channel-watch silence bias entirely β€” a DM IS ALWAYS
646
+ * ANSWERED. Mechanically it is LOAD-BEARING: `direct-mention` is what stamps `oe_addressing` through
647
+ * the divert, which marks the wake as OWED a reply and selects the `[direct]` preamble's reply
648
+ * mandate. πŸ”΄ IT IS NOT A DELIVERY PATH (OPE-1982): the addressed-prose bridge it used to arm is
649
+ * deleted, so a DM answer written as prose rather than a `reply` call is DROPPED on that channel
650
+ * like any other, and re-driven once towards the tool.
651
+ *
652
+ * `reason` is `dm`, not `mention`: the envelope's reason describes the SURFACE SHAPE. It is also the
653
+ * one reason that survives the OPE-744 collapse of `mention` into `channel-activity`, because no
654
+ * other attribute states "this is a direct-message room".
655
+ *
656
+ * NO `engagementTier`: that dial raises the bar for AMBIENT watching, and a DM is always answered.
657
+ *
658
+ * The `wakeId` is present because the unified channel is the delivery route: it is the id the model
659
+ * must echo to `reply`, and the only way a DM answer gets out. Pure β†’ unit-testable.
660
+ */
661
+ export declare function buildDiscordDmWake(input: DiscordDmWakeInput): NormalizedWake;
662
+ /**
663
+ * THE DOMAIN'S ADMISSION POLICY over a mention/name-drop hydration, for BOTH of this surface's wake roads (OPE-1800).
664
+ * πŸ”΄ THE DOMAIN'S POLICY, ON THIS READ TOO. A seated Discord wake takes TWO reads of the room: the since-last-wake catch-up, which has passed `seatAdmissionFor` since OPE-1726, and this hydration, which did not. So a `members-only` domain dropped a guest's message from the catch-up and then handed the seat the SAME message inside the context block. The policy is resolved by NAME from the caller's own domain key and applied by the shared core seam (channels/seat-admission.ts `admitContextLines`), which is the one mapping Slack's hydration takes β€” never a per-bundle copy.
665
+ * πŸ”΄ THE SEAT DECIDES, AND THE SEAT IS `grainUsesUnifiedWindow(grain)` ALONE β€” never "which road this wake took". That is the exact predicate Slack resolves `seated` from ({@link slackDomainUsesUnifiedWindow}, "the SAME fact … without the async privacy floor"), and matching it is what gives one domain ONE admission answer. A wake whose room HAS a seat but which falls through to `dispatchTurn`'s native road β€” the guild vouch absent, or no `receive` β€” is a seat STOOD DOWN, not an unseated room: access/window-routing.ts calls that state `native-degraded` and hooks/window-routing.ts audits it `window.routing_degraded`. Reading it as "no seat" would hand that domain a second admission answer, selected by a runtime vouch.
666
+ * πŸ”΄ A TRULY UNSEATED DEPLOYMENT KEEPS ITS WHOLE HYDRATION: `window.topology` unarmed, or no seat domain reaches the room, so `grainUsesUnifiedWindow` is false. There is no seat for a seat policy to govern, that hydration is the only context such a wake carries, and the shipped `wide` default would strip the attributable-bot lines it keeps for coherence.
667
+ * πŸ”΄ GRADE FIRST, THEN ADMIT. `lines` must already carry the per-author regrade ({@link gradeDiscordContextLines}): the policy judges the RESOLVED tier, and an ungraded person floors at `unauth`, so admitting first would make `members-only` refuse every human line in the room.
668
+ * Nothing admitted β‡’ `undefined`, the same shape an absent fetch produces β€” never an empty slot, which renders as a quiet room.
669
+ */
670
+ export declare function admitDiscordSeatLines(lines: readonly ContextLine[] | undefined, grain: SupervisorGrain, domainKey: string, isThread: boolean): readonly ContextLine[] | undefined;
671
+ /**
672
+ * The WINDOW road's use of {@link admitDiscordSeatLines}: admit, then derive the taint-join facts β€” both over ONE set.
673
+ * πŸ”΄ IT NO LONGER RENDERS (OPE-1749). It used to hand `divertDiscordWakeToUnifiedWindow` a PRE-RENDERED string, which took this road OUT of the core render β€” and with it out of the envelope de-duplication and the cap ORDER that `renderWake` applies through `hydrationSlices`. A seat whose carried TAIL overlapped this hydration then rendered each overlapping message TWICE and spent the ten-line cap doing it. The road now hands the structured lines down to the builder, exactly as every other surface does, and core decides what renders.
674
+ * πŸ”΄ ONE SET FEEDS BOTH OUTPUTS, so the join can never carry a line the policy REFUSED β€” which is the property that matters, because a refused line's bytes never reach the model. It is not an equality of counts: the core render applies its own bounds and the de-duplication drops the envelope's own copies, so FEWER lines render than the join marks. That direction over-taints, which is the safe one (OPE-475) β€” and a de-duplicated line's bytes did enter, through the envelope sibling that replaced it.
675
+ * Nothing admitted β‡’ EMPTY on both, the same shape an absent or empty fetch produces, never an empty context block.
676
+ */
677
+ export declare function discordSeatContextForWindow(lines: readonly ContextLine[] | undefined, source: string | undefined, grain: SupervisorGrain, domainKey: string, isThread: boolean): {
678
+ readonly lines: readonly ContextLine[];
679
+ readonly source?: string;
680
+ readonly ingestion: ReadonlyArray<ReturnType<typeof contextLineIngestion>>;
681
+ };
682
+ /**
683
+ * Divert one Discord wake into the unified window: get-or-create the DOMAIN session (the SAME
684
+ * registry row + rotation policy every other surface's live deps drive β€” github.ts
685
+ * liveGitHubSupervisorDeps β€” so a Discord wake lands in whatever generation the domain is on),
686
+ * build + render the wake with a host-minted wake-id, and hand it to the shared
687
+ * divertToUnifiedWindow (which stamps the auth and registers the durable wakeId→origin map
688
+ * entry BEFORE the send, inside unified.ts's fail-closed receive).
689
+ *
690
+ * πŸ”΄ NOTHING HERE REACTS TO A SESSION THAT DIES AT TURN TIME, and nothing can: the dispatch awaits
691
+ * only the ENQUEUE, so a turn-time throw cannot reject it, and eve emits no userland event for the
692
+ * unreadable-World class (channels/session-resume.ts holds the measurement). This comment used to
693
+ * promise a force-rotate-and-re-deliver arm; OPE-174 removed that arm as provably inert. The
694
+ * remedy that does work is PRE-DISPATCH β€” the OPE-599 runtime-change claim below, which rotates
695
+ * BEFORE the wake goes out so the trigger lands in a session the running eve build can read.
696
+ */
697
+ /**
698
+ * EXPORTED FOR THE INGRESS CONTRACT TEST (OPE-1719), not for another caller. `dispatchTurn` is the
699
+ * only production caller and it is one file away. `scripts/owned-thread-ingress-wiring-test.ts`
700
+ * drives THIS function β€” the real Discord ingress divert, over the real store β€” because a test that
701
+ * reimplemented the wake dep would stay green after somebody moved this leg back to the raw divert,
702
+ * which is the exact regression the ticket closes.
703
+ */
704
+ export declare function divertDiscordWakeToUnifiedWindow(input: {
705
+ readonly msg: DiscordRelayMessage;
706
+ readonly auth: OpenEverywhereDiscordAuth;
707
+ readonly partition: Partition;
708
+ /** Guaranteed `unified` + `singleWindow` + `domainName` by the caller's gate. */
709
+ readonly grain: SupervisorGrain;
710
+ readonly addressing: "direct-mention" | "channel-watch" | "name-drop";
711
+ /**
712
+ * The caller's OWN `discordSingleWindowPrivacyOk` verdict β€” an operator-VOUCHED guild surface. It
713
+ * is the same fact the trigger's trust tier is resolved from, and the catch-up's per-author trust
714
+ * resolver reads it rather than hardcoding one (channels/seat-catch-up.ts `surfaceInternal` says
715
+ * why). Today the only production caller gates this whole divert on it, so it is always `true`
716
+ * there; passing it keeps that a STRUCTURAL fact rather than a convention a second caller could
717
+ * break, since this function is exported.
718
+ */
719
+ readonly privacyOk: boolean;
720
+ /** The ENROLMENT fact for a judged wake β€” see {@link DiscordUnifiedWakeInput.threadEngaged}. The
721
+ * caller reads it off the engagement row it already fetched for the dispatch gate. */
722
+ readonly threadEngaged?: boolean;
723
+ readonly receive: UnifiedReceive;
724
+ /** The ADMITTED backscroll lines, STRUCTURED, possibly empty (OPE-1749). */
725
+ readonly contextLines: readonly ContextLine[];
726
+ /** `renderContext`'s provenance label for {@link contextLines}. */
727
+ readonly contextSource?: string;
728
+ /** OPE-475: the taint join's per-line facts for the SAME backscroll lines `contextLines` holds
729
+ * ({@link gradeDiscordContextLines} output, mapped via `contextLineIngestion`) β€” carried
730
+ * alongside them because the join is the CALLER's to build: the render drops the envelope's own
731
+ * copies, whose bytes still entered through those siblings. Possibly empty. */
732
+ readonly contextIngestion: ReadonlyArray<ReturnType<typeof contextLineIngestion>>;
733
+ }): Promise<void>;
734
+ /** The `send` shape this channel dispatches with (SendFn parametrized by our state). The first
735
+ * arg is a plain string OR eve's SendPayload `{ message, context }` β€” `context` strings are
736
+ * appended as user messages before the delivery turn (used for the wake/mention backscroll). */
737
+ export type DiscordGatewaySend = (message: string | {
738
+ readonly message: string;
739
+ readonly context?: readonly string[];
740
+ }, options: {
741
+ auth: OpenEverywhereDiscordAuth;
742
+ continuationToken: string;
743
+ state: DiscordGatewayState;
744
+ }) => Promise<unknown>;
745
+ /**
746
+ * Resolve the wake decision (consulting the engagement registry only when needed) and, if it
747
+ * passes, start/resume the durable session. A mention also ENGAGES the channel so later
748
+ * non-mention replies keep waking it (reply-without-re-tagging, exactly like Slack). Runs in
749
+ * the background (handed to waitUntil) so the route acks fast.
750
+ */
751
+ export declare function dispatchTurn(msg: DiscordRelayMessage, auth: OpenEverywhereDiscordAuth, send: DiscordGatewaySend, createThread?: (channelId: string, messageId: string, name: string) => Promise<string | null>, resolveParent?: (channelId: string) => Promise<string | undefined>, receive?: UnifiedReceive): Promise<void>;
752
+ export declare function engagementCoordsFor(state: DiscordGatewayState): {
753
+ p: Partition;
754
+ key: string;
755
+ } | null;
756
+ /**
757
+ * Promote the surface to PARTICIPATING on a POSTED reply (incl. a card-only reply β€” the card IS
758
+ * the reply), mirroring slack.ts's post branch: a warm (name-drop) surface the agent actually
759
+ * answered goes sticky, its silent streak resets, and its failures go loud.
760
+ *
761
+ * THE DISCORD DIVERGENCE (why this reads-then-promotes instead of blindly engaging): Slack promotes
762
+ * unconditionally because its engagement grain is the THREAD, so a name-drop reply merely
763
+ * establishing THREAD engagement is harmless. Discord's BASE-channel engagement grain is the WHOLE
764
+ * CHANNEL, and a base-channel name-drop DELIBERATELY does not engage (dispatchTurn β€” else every
765
+ * subsequent channel message becomes a judged model turn, the "engaged-channel surprise" from live
766
+ * testing). So a base-channel name-drop reply must NOT create whole-channel engagement. We only
767
+ * PROMOTE an EXISTING warm record (a thread name-drop engaged at dispatch, or a mention/engaged
768
+ * surface); no record β‡’ a base-channel name-drop β‡’ leave it un-engaged. Best-effort: engagement
769
+ * must never fail a delivered reply.
770
+ */
771
+ export declare function promoteParticipatingOnReply(state: DiscordGatewayState): Promise<void>;
772
+ /**
773
+ * Warm auto-disengage on a JUDGED-SILENT turn (mirrors slack.ts's silent branch): a WARM
774
+ * (participating:false) surface that keeps judging NO_REPLY is shed after WARM_SILENT_LIMIT turns,
775
+ * so a one-off name-drop that never earns a reply doesn't leave the bot quietly following forever.
776
+ * PARTICIPATING surfaces are EXEMPT β€” they're sticky, only an explicit stop disengages them β€” so a
777
+ * missing record OR participating:true is a no-op. Best-effort; engagement bookkeeping never throws
778
+ * out of the completion handler.
779
+ */
780
+ export declare function maybeShedWarmSurface(state: DiscordGatewayState): Promise<void>;
781
+ declare const _default: import("eve/channels").Channel<DiscordGatewayState, Record<string, unknown>, Record<string, unknown>>;
782
+ export default _default;
783
+ //# sourceMappingURL=discord-gateway.d.ts.map