@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,3256 @@
|
|
|
1
|
+
// The Discord GATEWAY ingestion surface โ Milestone 2 of the Discord Gateway feature.
|
|
2
|
+
//
|
|
3
|
+
// Discord delivers ambient channel/DM messages (MESSAGE_CREATE) ONLY over a persistent
|
|
4
|
+
// Gateway WebSocket, never to an HTTP endpoint โ so a serverless deploy can't receive them
|
|
5
|
+
// directly. Milestone 1 (deploy/discord-relay/) is a tiny always-on process that holds that
|
|
6
|
+
// socket and forwards each message here as a signed HTTP POST. THIS file is the receiving
|
|
7
|
+
// end: it verifies the relay's HMAC, reconstructs the real Discord identity, wake-gates like
|
|
8
|
+
// every other surface, dispatches an agent turn on a durable-resume session, and posts the
|
|
9
|
+
// reply back to Discord via REST.
|
|
10
|
+
//
|
|
11
|
+
// MOUNTING CHOICE mirrors channels/webhooks.ts: a plain `defineChannel` with one POST route
|
|
12
|
+
// (`/eve/v1/discord-gateway`), not a front-route over an existing channel (slack-front.ts).
|
|
13
|
+
// The relay POSTs to a BRAND-NEW public surface with no existing owner, so the least-invasive
|
|
14
|
+
// mount is the first-class route primitive โ exactly the shape webhooks.ts uses. Unlike the
|
|
15
|
+
// interaction-based channels/discord.ts (Discord's HTTP Interactions โ slash commands only),
|
|
16
|
+
// this path carries ambient messages, so it does its OWN identity capture + dispatch and posts
|
|
17
|
+
// replies with the bot token over REST (there is no interaction token to edit/followup).
|
|
18
|
+
//
|
|
19
|
+
// DISPATCH + RESUME. A route handler starts/continues a session via `args.send` (the same
|
|
20
|
+
// primitive eve's own channel wrappers use internally): the continuation token keys the
|
|
21
|
+
// durable session on the Discord channel id, so subsequent messages RESUME it โ real
|
|
22
|
+
// multi-turn conversation. The turn runs async (waitUntil); its final reply is posted from
|
|
23
|
+
// the `message.completed` event handler, NOT synchronously in the route. The target channel
|
|
24
|
+
// id is threaded to that handler via the session `state` /
|
|
25
|
+
// `context`, the way eve's discordChannel threads its own channelId.
|
|
26
|
+
//
|
|
27
|
+
// ๐ด A DM DOES NOT TAKE THAT PATH ANY MORE (OPE-356). A `channelType` 1 (DM) or 3 (group DM) is
|
|
28
|
+
// DIVERTED into the DM's own first-class standing window โ `divertDiscordDmToWindow` below โ so it
|
|
29
|
+
// gets a rotatable, WAKEABLE address instead of a per-channel session eve alone can address, and its
|
|
30
|
+
// reply comes back through the unified channel's arm rather than this file's `message.completed`.
|
|
31
|
+
// The paragraph above still describes every GUILD wake; since OPE-368 removed the
|
|
32
|
+
// `OE_DM_SEAT_ROTATION` escape hatch it no longer describes any DM at all.
|
|
33
|
+
//
|
|
34
|
+
// FAIL-CLOSED. No OE_RELAY_SECRET, or a missing/malformed/mismatched signature โ 401, never
|
|
35
|
+
// dispatch. The payload is untrusted JSON: every field is probed defensively, bot authors are
|
|
36
|
+
// dropped, and unresolvable payloads are rejected rather than guessed at.
|
|
37
|
+
//
|
|
38
|
+
// Env this surface reads at runtime:
|
|
39
|
+
// OE_DM_SEAT_ROTATE_* (optional) the DM window's rotation thresholds (core channels/dm-seat.ts),
|
|
40
|
+
// never the supervisor's. A DM window itself is unconditional since OPE-368
|
|
41
|
+
// retired the OE_DM_SEAT_ROTATION master switch.
|
|
42
|
+
// OE_RELAY_SECRET (required) HMAC-SHA256 key shared with the relay; unset โ all 401.
|
|
43
|
+
// DISCORD_BOT_TOKEN (required to reply) bot token for the REST create-message/thread calls.
|
|
44
|
+
// discord.autoThread (optional, default ON) a mention in a base channel opens a thread and
|
|
45
|
+
// the conversation re-keys onto it; set "false"/"0" for inline replies.
|
|
46
|
+
// discord.statusUx (optional, default ON) show "working" affordances during a turn โ a
|
|
47
|
+
// ๐ "seen/working" reaction on direct addresses (mention/DM; on
|
|
48
|
+
// auto-threaded mentions it is REMOVED when the answer lands โ absence
|
|
49
|
+
// = done, โ
stays reserved for humans) and Discord's NATIVE typing
|
|
50
|
+
// indicator while the turn runs (re-triggered on turn/tool events; it
|
|
51
|
+
// expires by itself, so there is nothing to track or clean up across
|
|
52
|
+
// turns); set "false"/"0" off.
|
|
53
|
+
|
|
54
|
+
import { defineChannel, POST, type RouteHandlerArgs } from "eve/channels";
|
|
55
|
+
import { buildDiscordAuth, type OpenEverywhereDiscordAuth } from "./discord-identity.js";
|
|
56
|
+
import { threadParentChannelId } from "./discord-channel-info.js";
|
|
57
|
+
import { splitForDiscord } from "./discord-post.js";
|
|
58
|
+
import { discordFetch, type DiscordFetchDeps } from "./discord-rest.js";
|
|
59
|
+
import { RateLimitExhaustedError, type RetryPolicy } from "@open-everywhere/core/agent/adapter-api.js";
|
|
60
|
+
import {
|
|
61
|
+
finalMessageAction,
|
|
62
|
+
nameDropPattern,
|
|
63
|
+
wakeNameDropTokens,
|
|
64
|
+
NO_REPLY_SENTINEL,
|
|
65
|
+
shouldRotate,
|
|
66
|
+
renderWake,
|
|
67
|
+
mkMessageTrigger,
|
|
68
|
+
supervisorGrainFor,
|
|
69
|
+
unifiedSupervisorToken,
|
|
70
|
+
grainUsesUnifiedWindow,
|
|
71
|
+
mintRotationToken,
|
|
72
|
+
claimRuntimeRotation,
|
|
73
|
+
stampMintedRuntime,
|
|
74
|
+
mintWakeId,
|
|
75
|
+
discordVendorEventTime,
|
|
76
|
+
spreadVendorTs,
|
|
77
|
+
singleWindowMentionPolicy,
|
|
78
|
+
divertToUnifiedWindow,
|
|
79
|
+
divertInboundToUnifiedWindow,
|
|
80
|
+
verifyWebhookSignature,
|
|
81
|
+
cancelAnchoredTurn,
|
|
82
|
+
turnAnchorKey,
|
|
83
|
+
STOP_HAND_UNICODE,
|
|
84
|
+
STOP_HAND_SLACK_NAME,
|
|
85
|
+
type AttachSessionForCancel,
|
|
86
|
+
domainKeyOf,
|
|
87
|
+
resolvePartition,
|
|
88
|
+
nameDropWakeEnabled,
|
|
89
|
+
seatCatchUp,
|
|
90
|
+
seatCatchUpSinceMs,
|
|
91
|
+
seatAdmissionFor,
|
|
92
|
+
admitContextLines,
|
|
93
|
+
backscrollTrustResolver,
|
|
94
|
+
engagementTierFor,
|
|
95
|
+
getConfig,
|
|
96
|
+
deploymentOrg,
|
|
97
|
+
isVouchedInternal,
|
|
98
|
+
resolveWakeTrustFromContext,
|
|
99
|
+
stampNonMemberChatAuth,
|
|
100
|
+
renderContext,
|
|
101
|
+
contextLineIngestion,
|
|
102
|
+
resolveExternalPolicy,
|
|
103
|
+
externalPolicyOverrideFor,
|
|
104
|
+
withholdsBody,
|
|
105
|
+
roomIsSilenced,
|
|
106
|
+
silentRoomNotice,
|
|
107
|
+
silentNoticeKey,
|
|
108
|
+
EXTERNAL_SENDER_NOTICE,
|
|
109
|
+
EXTERNAL_MENTION_ACK,
|
|
110
|
+
owesSuppressedMentionAck,
|
|
111
|
+
suppressionRendering,
|
|
112
|
+
type IngestedChatMessage,
|
|
113
|
+
type ExternalPolicy,
|
|
114
|
+
getOperationalStore,
|
|
115
|
+
appendAudit,
|
|
116
|
+
type AuditInput,
|
|
117
|
+
type FinalMessageAction,
|
|
118
|
+
type NormalizedWake,
|
|
119
|
+
type WakeEngagementTier,
|
|
120
|
+
type WakeTrust,
|
|
121
|
+
type AuthorTrustQuery,
|
|
122
|
+
type SupervisorGrain,
|
|
123
|
+
type SingleWindowMentionPolicy,
|
|
124
|
+
type UnifiedWakeOrigin,
|
|
125
|
+
type UnifiedWindowWake,
|
|
126
|
+
type Partition,
|
|
127
|
+
type NameDropAuditKey,
|
|
128
|
+
type ContextLine,
|
|
129
|
+
type BindableContext,
|
|
130
|
+
carryTailForward,
|
|
131
|
+
type WakeMessageInput,
|
|
132
|
+
recordWakeTail,
|
|
133
|
+
tailAuthorGrain,
|
|
134
|
+
dmWindowRegistryKey,
|
|
135
|
+
auditSupervisorFailure,
|
|
136
|
+
healPrunedDeploymentStrand,
|
|
137
|
+
engagedFollowUpContract,
|
|
138
|
+
dispatchNativeWakeWithRetry,
|
|
139
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
140
|
+
|
|
141
|
+
// Re-exported so every existing importer (scripts/discord-gateway-test.ts, the completion
|
|
142
|
+
// post-back below) keeps compiling โ the implementation moved to discord-post.ts so the
|
|
143
|
+
// unified reply arm (unified-reply.ts) can split without importing this whole channel module.
|
|
144
|
+
export { splitForDiscord };
|
|
145
|
+
import {
|
|
146
|
+
bumpThreadSilentStreak,
|
|
147
|
+
disengageThread,
|
|
148
|
+
getThreadEngagement,
|
|
149
|
+
markThreadEngaged,
|
|
150
|
+
auditNameDropWoke,
|
|
151
|
+
auditNameDropSilent,
|
|
152
|
+
WARM_SILENT_LIMIT,
|
|
153
|
+
ingressSpendGate,
|
|
154
|
+
type SpendGateDoor,
|
|
155
|
+
consumeCardPostedThisTurn,
|
|
156
|
+
peekCardPostedThisTurn,
|
|
157
|
+
consumeRepliedThisTurn,
|
|
158
|
+
dropClosingProse,
|
|
159
|
+
isAllowedDriver,
|
|
160
|
+
parseDriverAllowlist,
|
|
161
|
+
// THE DM SEAT (OPE-356) โ the same core mint/touch/flag every first-classed DM window uses; the
|
|
162
|
+
// Slack front route reaches for exactly these three.
|
|
163
|
+
getOrCreateDmSeat,
|
|
164
|
+
touchDmSeat,
|
|
165
|
+
buildIngestedReaction,
|
|
166
|
+
ingestReaction,
|
|
167
|
+
liveReactionSink,
|
|
168
|
+
type ChatAuditPartition,
|
|
169
|
+
type ReactionSinkDeps,
|
|
170
|
+
type ReactionIngestOutcome,
|
|
171
|
+
type ReactionVerb,
|
|
172
|
+
consumeNoReplyThisTurn,
|
|
173
|
+
consumeStandDownSilenceThisTurn,
|
|
174
|
+
STAND_DOWN_SILENCE_REASON,
|
|
175
|
+
NO_REPLY_SENTINEL_REASON,
|
|
176
|
+
auditNoReplySentinelObserved,
|
|
177
|
+
getSettings,
|
|
178
|
+
authPrincipalId,
|
|
179
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
180
|
+
import { fetchDiscordBackscrollLines, gradeDiscordContextLines, DISCORD_RAW_FETCH_REACHBACK_HOURS } from "./discord-backscroll.js";
|
|
181
|
+
|
|
182
|
+
function log(msg: string): void {
|
|
183
|
+
console.log(`[open-everywhere:discord-gateway] ${msg}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- session state ----------------------------------------------------------
|
|
187
|
+
// The things event handlers need that AREN'T on the event payload, carried on the durable
|
|
188
|
+
// session state. The load-bearing eve fact (learned the hard way โ an earlier design edited
|
|
189
|
+
// a PREVIOUS turn's message on resumed sessions): `send({ state })` seeds state ONLY when it
|
|
190
|
+
// STARTS a session. On the resume path eve's deliver payload carries just message/context โ
|
|
191
|
+
// the state option is silently dropped (node_modules/eve/.../channel/send.js), so a resumed
|
|
192
|
+
// session keeps its old state. โ only facts that are CORRECT for the session's whole life may
|
|
193
|
+
// ride here. Event handlers do get the LIVE state object (`channel.state`, eve write-back,
|
|
194
|
+
// auto-snapshotted at step boundaries) for mutations โ used to consume-once below.
|
|
195
|
+
//
|
|
196
|
+
// `channelId` โ the REPLY-surface channel: the thread when auto-threaded, else the base channel.
|
|
197
|
+
// Stable for the session's life; the final reply is posted here.
|
|
198
|
+
// `originalChannelId` / `originalMessageId` โ the coordinates of the human's TRIGGERING message in
|
|
199
|
+
// the BASE channel, seeded ONLY on the auto-thread turn โ the one case where the reply lands
|
|
200
|
+
// away from the mention, so clearing the ๐ on the original message carries real signal AND
|
|
201
|
+
// the session is guaranteed new (a fresh thread id), so the seed is guaranteed correct. Consumed
|
|
202
|
+
// once by message.completed (cleared via the live state); never re-seeded on resumed turns.
|
|
203
|
+
export interface DiscordGatewayState {
|
|
204
|
+
channelId: string;
|
|
205
|
+
// `guildId` โ the guild (server) the reply surface lives in. Seeded so the completion handler
|
|
206
|
+
// can rebuild the ENGAGEMENT partition (mirrors Slack's teamId+channelId rebuild) to promote
|
|
207
|
+
// the surface to participating on a posted reply / bump the warm silent streak on a judged
|
|
208
|
+
// silence. Stable for the session's WHOLE life (a channel never changes guild), so it satisfies
|
|
209
|
+
// the send-state rule above (only session-lifelong facts may ride here). ABSENT on DMs (no
|
|
210
|
+
// guild โ no engagement โ correct) and on sessions created BEFORE this seed landed (both
|
|
211
|
+
// fail-soft: the completion handler simply skips engagement bookkeeping). It is NOT needed to
|
|
212
|
+
// rebuild the surfaceKey shape โ guild alone is the workspace segment; the engagement KEY is the
|
|
213
|
+
// reply channel id itself (`channelId`), exactly as dispatchTurn keys Discord engagement.
|
|
214
|
+
guildId?: string;
|
|
215
|
+
originalChannelId?: string;
|
|
216
|
+
originalMessageId?: string;
|
|
217
|
+
}
|
|
218
|
+
interface DiscordGatewayContext {
|
|
219
|
+
readonly channelId: string;
|
|
220
|
+
/** The LIVE mutable session state (eve write-back) โ consume-once fields are cleared here. */
|
|
221
|
+
readonly state: DiscordGatewayState;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- the relay payload (untrusted) ------------------------------------------
|
|
225
|
+
// The compact shape deploy/discord-relay/index.js sends. Kept structural + defensive:
|
|
226
|
+
// this is attacker-shaped JSON reaching a public URL, so nothing is assumed present or
|
|
227
|
+
// well-typed โ parseRelayPayload probes every field and rejects an unusable envelope.
|
|
228
|
+
export interface DiscordRelayMessage {
|
|
229
|
+
readonly id: string;
|
|
230
|
+
readonly content: string;
|
|
231
|
+
readonly channelId: string;
|
|
232
|
+
/** Discord's numeric channel `type` as a STRING (relay sends the enum number). */
|
|
233
|
+
readonly channelType?: string;
|
|
234
|
+
readonly guildId?: string;
|
|
235
|
+
readonly author: { readonly id: string; readonly username?: string; readonly bot: boolean };
|
|
236
|
+
readonly mentionsBot: boolean;
|
|
237
|
+
readonly referencedMessageId?: string;
|
|
238
|
+
/** The bot's own display name AS SEEN IN THIS GUILD (nickname, else global name) โ
|
|
239
|
+
* what a human actually types to address it. Sent by relay โฅ 5c67655; absent from an
|
|
240
|
+
* older relay, which simply keeps the wake gate mention/engagement-only (fail-closed). */
|
|
241
|
+
readonly botName?: string;
|
|
242
|
+
/** The receiving bot's user id โ multi-bot attribution (item 27): when N relay clients
|
|
243
|
+
* share one process, each forwards every message, and botId is how dedupe and persona
|
|
244
|
+
* routing tell the copies apart. Same relay payload as botName; optional for old relays. */
|
|
245
|
+
readonly botId?: string;
|
|
246
|
+
/** Whether the message author is a PENDING guild member (membership screening incomplete) โ
|
|
247
|
+
* the OPE-60 guest/unverified demotion signal. Discord stamps `member.pending` on gateway
|
|
248
|
+
* member objects; forwarded by a relay that includes it (absent from older relays โ the
|
|
249
|
+
* binding keeps today's fail-closed/vouch behaviour). Undefined for DMs (no guild member). */
|
|
250
|
+
readonly memberPending?: boolean;
|
|
251
|
+
/**
|
|
252
|
+
* The guild ROLE ids this author holds (Discord stamps `member.roles` on the gateway member
|
|
253
|
+
* object โ the SAME object `member.pending` rides on, so this costs no extra API call). The input
|
|
254
|
+
* to the author-trust register's membership patterns (OPE-516): a
|
|
255
|
+
* `{ membership: "discord/<guildId>/role:<roleId>" }` declaration matches an author whose roles
|
|
256
|
+
* include that id.
|
|
257
|
+
*
|
|
258
|
+
* Absent for a DM (no guild member) and from an older relay that omits the member object โ both
|
|
259
|
+
* simply mean no membership evidence, which the register leg treats as "matched nothing".
|
|
260
|
+
*/
|
|
261
|
+
readonly memberRoles?: readonly string[];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
265
|
+
return typeof v === "object" && v !== null;
|
|
266
|
+
}
|
|
267
|
+
function str(v: unknown): string | undefined {
|
|
268
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
269
|
+
}
|
|
270
|
+
/** Coerce a string OR a number (Discord's numeric channel type) to a non-empty string. */
|
|
271
|
+
function strOrNum(v: unknown): string | undefined {
|
|
272
|
+
if (typeof v === "string") return v.length > 0 ? v : undefined;
|
|
273
|
+
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Defensively parse the relay envelope. Returns null for anything not recognizably a relay
|
|
279
|
+
* MESSAGE_CREATE with the identity facts we MUST have to dispatch + reply โ a channel id (for
|
|
280
|
+
* the post-back + partition) and an author id (for identity). `content` may legitimately be
|
|
281
|
+
* empty (Message Content Intent disabled surfaces empty strings), so it isn't required here;
|
|
282
|
+
* the route drops empty-content turns separately. NEVER throws.
|
|
283
|
+
*/
|
|
284
|
+
export function parseRelayPayload(raw: unknown): DiscordRelayMessage | null {
|
|
285
|
+
if (!isObject(raw)) return null;
|
|
286
|
+
if (raw["source"] !== "discord-gateway-relay") return null; // not our relay's shape
|
|
287
|
+
const m = raw["message"];
|
|
288
|
+
if (!isObject(m)) return null;
|
|
289
|
+
const channelId = str(m["channelId"]);
|
|
290
|
+
const author = isObject(m["author"]) ? (m["author"] as Record<string, unknown>) : undefined;
|
|
291
|
+
const authorId = author ? str(author["id"]) : undefined;
|
|
292
|
+
if (!channelId || !authorId) return null; // no post-back target / no identity โ unusable
|
|
293
|
+
const bot = isObject(m["bot"]) ? (m["bot"] as Record<string, unknown>) : undefined;
|
|
294
|
+
const member = isObject(m["member"]) ? (m["member"] as Record<string, unknown>) : undefined;
|
|
295
|
+
return {
|
|
296
|
+
id: str(m["id"]) ?? "",
|
|
297
|
+
content: typeof m["content"] === "string" ? (m["content"] as string) : "",
|
|
298
|
+
channelId,
|
|
299
|
+
channelType: strOrNum(m["channelType"]),
|
|
300
|
+
guildId: str(m["guildId"]),
|
|
301
|
+
author: { id: authorId, username: str(author!["username"]), bot: author!["bot"] === true },
|
|
302
|
+
mentionsBot: m["mentionsBot"] === true,
|
|
303
|
+
referencedMessageId: str(m["referencedMessageId"]),
|
|
304
|
+
// OPE-60 guest/unverified signal โ forwarded by a relay that includes the member object;
|
|
305
|
+
// absent from older relays โ undefined โ today's fail-closed/vouch behaviour is unchanged.
|
|
306
|
+
memberPending: member && typeof member["pending"] === "boolean" ? (member["pending"] as boolean) : undefined,
|
|
307
|
+
// OPE-516 author-trust membership evidence, off the SAME member object. Defensive: only a real
|
|
308
|
+
// array of non-empty strings counts, and an empty result stays `undefined` so "the relay sent no
|
|
309
|
+
// member object" and "this member holds no roles" are not conflated in the shape (they resolve
|
|
310
|
+
// the same way, but the absence is honest).
|
|
311
|
+
memberRoles: Array.isArray(member?.["roles"])
|
|
312
|
+
? ((member!["roles"] as unknown[]).filter((r): r is string => typeof r === "string" && r.length > 0) as string[])
|
|
313
|
+
: undefined,
|
|
314
|
+
botName: bot ? str(bot["name"]) : undefined,
|
|
315
|
+
// WHICH bot this delivery came through โ the multi-bot attribution field (item 27:
|
|
316
|
+
// N relay clients in one process each forward every message; botId is how dedupe and
|
|
317
|
+
// persona routing tell the copies apart). Carried from the relay since 5c67655.
|
|
318
|
+
botId: bot ? str(bot["id"]) : undefined,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// --- inbound reactions (untrusted feedback signal) --------------------------
|
|
323
|
+
// Discord delivers MESSAGE_REACTION_ADD / MESSAGE_REACTION_REMOVE over the Gateway (needs
|
|
324
|
+
// the GUILD_MESSAGE_REACTIONS intent), which the relay forwards as its OWN payload shape
|
|
325
|
+
// (event: "MESSAGE_REACTION_ADD"/"...REMOVE", a `reaction` object instead of `message`).
|
|
326
|
+
// This is the ingestion half of channels/reaction-ingest.ts: a reaction on the bot's own
|
|
327
|
+
// reply is a lightweight human FEEDBACK signal (recorded), any other reaction is telemetry
|
|
328
|
+
// only. The relay drops bot reactors (incl. our own ๐ status reactions) before forwarding.
|
|
329
|
+
|
|
330
|
+
export interface DiscordRelayReaction {
|
|
331
|
+
readonly verb: ReactionVerb;
|
|
332
|
+
/** The reacted message's id + channel. */
|
|
333
|
+
readonly messageId: string;
|
|
334
|
+
readonly channelId: string;
|
|
335
|
+
readonly guildId?: string;
|
|
336
|
+
readonly channelType?: string;
|
|
337
|
+
/** The emoji: a unicode char (๐) or a custom emoji NAME. Bucketed by classifyEmoji. */
|
|
338
|
+
readonly emoji: string;
|
|
339
|
+
readonly reactorId: string;
|
|
340
|
+
/** The AUTHOR of the reacted message, when the relay could resolve it โ drives onBotReply. */
|
|
341
|
+
readonly messageAuthorId?: string;
|
|
342
|
+
/** The receiving bot's own user id (relay-supplied) โ onBotReply compares against it. */
|
|
343
|
+
readonly botId?: string;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Defensively parse a relay REACTION envelope, or null when it isn't one (wrong source/event,
|
|
348
|
+
* or missing the ids ingestion needs). NEVER throws. Distinguished from a message payload by
|
|
349
|
+
* the `event` discriminator + a `reaction` object (vs `message`).
|
|
350
|
+
*/
|
|
351
|
+
export function parseRelayReaction(raw: unknown): DiscordRelayReaction | null {
|
|
352
|
+
if (!isObject(raw)) return null;
|
|
353
|
+
if (raw["source"] !== "discord-gateway-relay") return null;
|
|
354
|
+
const ev = raw["event"];
|
|
355
|
+
if (ev !== "MESSAGE_REACTION_ADD" && ev !== "MESSAGE_REACTION_REMOVE") return null;
|
|
356
|
+
const r = raw["reaction"];
|
|
357
|
+
if (!isObject(r)) return null;
|
|
358
|
+
const messageId = str(r["messageId"]);
|
|
359
|
+
const channelId = str(r["channelId"]);
|
|
360
|
+
const reactorId = str(r["reactorId"]);
|
|
361
|
+
const emoji = str(r["emoji"]);
|
|
362
|
+
if (!messageId || !channelId || !reactorId || !emoji) return null;
|
|
363
|
+
return {
|
|
364
|
+
verb: ev === "MESSAGE_REACTION_ADD" ? "added" : "removed",
|
|
365
|
+
messageId,
|
|
366
|
+
channelId,
|
|
367
|
+
guildId: str(r["guildId"]),
|
|
368
|
+
channelType: strOrNum(r["channelType"]),
|
|
369
|
+
emoji,
|
|
370
|
+
reactorId,
|
|
371
|
+
messageAuthorId: str(r["messageAuthorId"]),
|
|
372
|
+
botId: str(r["botId"]),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --- relay liveness heartbeat (OPE-775) --------------------------------------
|
|
377
|
+
// OPE-640 shipped a REPORTED census signal off `discord.dispatched`'s age, and named its own
|
|
378
|
+
// limit: absence has two causes it cannot tell apart โ a dead relay and a guild where nobody
|
|
379
|
+
// spoke. This is the fix: a heartbeat the RELAY ITSELF POSTs on a timer, independent of any
|
|
380
|
+
// guild's traffic, which the route below records once `relayGate` accepts the POST โ so its age
|
|
381
|
+
// means something even across a silent weekend. The relay signs it
|
|
382
|
+
// with the SAME OE_RELAY_SECRET HMAC as a forwarded message (verified by `relayGate` over the
|
|
383
|
+
// raw body before ANY payload is parsed, this envelope included), and carries no guild content โ
|
|
384
|
+
// a timestamp and a relay identifier is the whole payload, matching the ticket's security note.
|
|
385
|
+
|
|
386
|
+
export interface DiscordRelayHeartbeat {
|
|
387
|
+
/** Identifies the relay PROCESS (its own bot user id) โ never a guild/channel/user fact. */
|
|
388
|
+
readonly relayId: string;
|
|
389
|
+
/** The relay's own clock, epoch milliseconds, at the moment it sent this beat. */
|
|
390
|
+
readonly ts: number;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Defensively parse a relay HEARTBEAT envelope, or null when it isn't one (wrong source/event, or
|
|
395
|
+
* missing a field). NEVER throws. Distinguished from a message/reaction envelope by the `event`
|
|
396
|
+
* discriminator + a `heartbeat` object (vs `message`/`reaction`) โ mutually exclusive with both,
|
|
397
|
+
* pinned by scripts/discord-gateway-test.ts.
|
|
398
|
+
*/
|
|
399
|
+
export function parseRelayHeartbeat(raw: unknown): DiscordRelayHeartbeat | null {
|
|
400
|
+
if (!isObject(raw)) return null;
|
|
401
|
+
if (raw["source"] !== "discord-gateway-relay") return null;
|
|
402
|
+
if (raw["event"] !== "HEARTBEAT") return null;
|
|
403
|
+
const hb = raw["heartbeat"];
|
|
404
|
+
if (!isObject(hb)) return null;
|
|
405
|
+
const relayId = str(hb["relayId"]);
|
|
406
|
+
const ts = typeof hb["ts"] === "number" && Number.isFinite(hb["ts"]) ? (hb["ts"] as number) : undefined;
|
|
407
|
+
if (!relayId || ts === undefined) return null;
|
|
408
|
+
return { relayId, ts };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The audit row a heartbeat writes. Pure โ no I/O โ so the shape is testable without a live
|
|
412
|
+
* store; the route below is the only caller that actually persists it via `appendAudit`. The
|
|
413
|
+
* action name (`discord.heartbeat`) is what `agent/ops/census.ts`'s `CHANNEL_REPORTED_HEARTBEATS`
|
|
414
|
+
* reads by. `workspace`/`surface` name the RELAY, never a guild โ this signal is guild-agnostic
|
|
415
|
+
* by design, which is exactly what lets it separate a dead relay from a quiet one. */
|
|
416
|
+
export function discordHeartbeatAuditRow(heartbeat: DiscordRelayHeartbeat, org: string): AuditInput {
|
|
417
|
+
return {
|
|
418
|
+
org,
|
|
419
|
+
platform: "discord",
|
|
420
|
+
workspace: "relay",
|
|
421
|
+
surface: "heartbeat",
|
|
422
|
+
principalId: heartbeat.relayId,
|
|
423
|
+
action: "discord.heartbeat",
|
|
424
|
+
detail: { relayId: heartbeat.relayId, sentAtIso: new Date(heartbeat.ts).toISOString() },
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Dependencies for {@link routeDiscordReaction} โ injected so parse+route+record is unit-tested
|
|
429
|
+
* without a live store. `attachSession` is eve's cancel door, present ONLY when the route was
|
|
430
|
+
* built with it (the โ stop arm) โ absent โ a โ is ingested as an ordinary reaction. */
|
|
431
|
+
export interface DiscordReactionRouteDeps {
|
|
432
|
+
readonly sink: ReactionSinkDeps;
|
|
433
|
+
readonly attachSession?: AttachSessionForCancel;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Is this reaction the โ stop affordance? Discord delivers a unicode emoji as the char itself, but
|
|
437
|
+
* a relay (or a custom emoji of the same name) may deliver the NAME โ accept both spellings rather
|
|
438
|
+
* than making the stop button depend on which one arrived. */
|
|
439
|
+
function isStopHand(emoji: string): boolean {
|
|
440
|
+
const bare = emoji.trim();
|
|
441
|
+
return bare === STOP_HAND_UNICODE || bare === STOP_HAND_SLACK_NAME;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The โ STOP arm (OPE-418) โ cancel the turn anchored on the reacted message.
|
|
446
|
+
*
|
|
447
|
+
* ๐ด THE REACTOR GUARD HERE FAILS CLOSED, and that is a DIFFERENT posture from the feedback path
|
|
448
|
+
* below on purpose. The ingest path skips its bot-reactor drop when `botId` is unresolved, which
|
|
449
|
+
* costs a spurious telemetry row โ harmless. Behind `cancel()` the same fail-OPEN would be a
|
|
450
|
+
* SELF-CANCEL: the bot's own โ (it places one on every delegating turn) would cancel the very turn
|
|
451
|
+
* that placed it. So a stop requires a POSITIVELY RESOLVED, non-bot reactor: no `botId` โ no stop.
|
|
452
|
+
*/
|
|
453
|
+
async function routeStopHandReaction(rx: DiscordRelayReaction, attachSession: AttachSessionForCancel): Promise<ReactionIngestOutcome> {
|
|
454
|
+
if (!rx.botId) {
|
|
455
|
+
return { recorded: false, onBotReply: false, reason: "stop: bot identity unresolved โ refusing to cancel (fail-closed)" };
|
|
456
|
+
}
|
|
457
|
+
if (rx.reactorId === rx.botId) {
|
|
458
|
+
return { recorded: false, onBotReply: false, reason: "stop: the reactor is the bot itself โ never a self-cancel" };
|
|
459
|
+
}
|
|
460
|
+
const auth = buildDiscordAuth({ userId: rx.reactorId, guildId: rx.guildId, channelId: rx.channelId, channelType: rx.channelType });
|
|
461
|
+
const p = resolvePartition(ctxFromAuth(auth));
|
|
462
|
+
const result = await cancelAnchoredTurn({
|
|
463
|
+
org: p.org,
|
|
464
|
+
platform: "discord",
|
|
465
|
+
workspace: p.workspace,
|
|
466
|
+
requestedBy: rx.reactorId,
|
|
467
|
+
via: "reaction",
|
|
468
|
+
// The reaction knows EXACTLY which message it landed on, so it resolves by the sharp key and
|
|
469
|
+
// never falls through to the channel's newest-turn guess.
|
|
470
|
+
query: { anchorKey: turnAnchorKey("discord", rx.channelId, rx.messageId)! },
|
|
471
|
+
attachSession,
|
|
472
|
+
});
|
|
473
|
+
return { recorded: false, onBotReply: false, reason: `stop: ${result.outcome}` };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Route one parsed Discord reaction into the surface-neutral ingestion seam. The relay already
|
|
478
|
+
* dropped bot reactors, but we double-check against the relayed botId (defence in depth) so the
|
|
479
|
+
* bot's own reaction is never recorded as feedback. onBotReply = the reacted message was authored
|
|
480
|
+
* by the bot; fail-safe when either id is missing (telemetry only, never a false feedback row).
|
|
481
|
+
*/
|
|
482
|
+
export async function routeDiscordReaction(rx: DiscordRelayReaction, deps: DiscordReactionRouteDeps): Promise<ReactionIngestOutcome> {
|
|
483
|
+
// โ FIRST, and only on ADD. โ is a MECHANISM, not feedback: it means "stop this", so it must
|
|
484
|
+
// never also be recorded as a sentiment signal about the message it sits on. Its REMOVAL is
|
|
485
|
+
// meaningless (the host removes it at turn end) and must never trigger anything.
|
|
486
|
+
if (rx.verb === "added" && isStopHand(rx.emoji) && deps.attachSession) {
|
|
487
|
+
return routeStopHandReaction(rx, deps.attachSession);
|
|
488
|
+
}
|
|
489
|
+
if (rx.botId && rx.reactorId === rx.botId) {
|
|
490
|
+
return { recorded: false, onBotReply: false, reason: "reactor is the bot itself โ ignored (not feedback)" };
|
|
491
|
+
}
|
|
492
|
+
const onBotReply = Boolean(rx.botId) && rx.messageAuthorId === rx.botId;
|
|
493
|
+
// Reuse the identity/partition seam (no channelType needed for surfaceKey resolution).
|
|
494
|
+
const auth = buildDiscordAuth({ userId: rx.reactorId, guildId: rx.guildId, channelId: rx.channelId, channelType: rx.channelType });
|
|
495
|
+
const p = resolvePartition(ctxFromAuth(auth));
|
|
496
|
+
const reaction = buildIngestedReaction({
|
|
497
|
+
surface: "discord",
|
|
498
|
+
verb: rx.verb,
|
|
499
|
+
reactorId: rx.reactorId,
|
|
500
|
+
emoji: rx.emoji,
|
|
501
|
+
target: { channelId: rx.channelId, messageId: rx.messageId },
|
|
502
|
+
onBotReply,
|
|
503
|
+
partition: p,
|
|
504
|
+
});
|
|
505
|
+
return ingestReaction(reaction, deps.sink);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Production deps for the Discord reaction route: the live audit/telemetry sink, plus eve's cancel
|
|
509
|
+
* door when the route has one (it is only reachable as `args.attachSession`). */
|
|
510
|
+
function liveDiscordReactionDeps(attachSession?: AttachSessionForCancel): DiscordReactionRouteDeps {
|
|
511
|
+
return { sink: liveReactionSink(), ...(attachSession ? { attachSession } : {}) };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// --- HMAC gate (fail-closed) ------------------------------------------------
|
|
515
|
+
// The relay signs `x-oe-relay-signature: sha256=<hex>` = HMAC-SHA256(rawBody, OE_RELAY_SECRET).
|
|
516
|
+
// That is byte-for-byte the same header format channels/webhooks.ts uses, so we reuse its
|
|
517
|
+
// constant-time `verifyWebhookSignature` verbatim rather than reinventing the compare. The one
|
|
518
|
+
// addition is the fail-closed no-secret case: with no shared secret we CANNOT authenticate the
|
|
519
|
+
// sender, so every delivery is rejected (never a dev bypass here โ this is a real ingress).
|
|
520
|
+
|
|
521
|
+
export interface RelayRejection {
|
|
522
|
+
readonly status: number;
|
|
523
|
+
readonly reason: string;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Gate one relay delivery. Returns null when it may proceed, else the {@link RelayRejection}.
|
|
528
|
+
* `secret` defaults to the live env read but is injectable so the gate is unit-tested without
|
|
529
|
+
* touching process.env. Fail-closed: unset/empty secret โ 401; missing/malformed/mismatched
|
|
530
|
+
* signature โ 401 (verifyWebhookSignature returns false for a null or non-`sha256=` header).
|
|
531
|
+
*/
|
|
532
|
+
export function relayGate(
|
|
533
|
+
signatureHeader: string | null,
|
|
534
|
+
rawBody: string,
|
|
535
|
+
secret: string | undefined = process.env.OE_RELAY_SECRET,
|
|
536
|
+
): RelayRejection | null {
|
|
537
|
+
if (!secret) {
|
|
538
|
+
return { status: 401, reason: "OE_RELAY_SECRET is not set โ rejecting unverified relay delivery" };
|
|
539
|
+
}
|
|
540
|
+
if (!verifyWebhookSignature(secret, rawBody, signatureHeader)) {
|
|
541
|
+
return { status: 401, reason: "invalid relay signature" };
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Reconstruct the dispatch auth from a relay message, or null to DROP. Reuses
|
|
548
|
+
* buildDiscordAuth verbatim (channels/discord.ts uses the same for the interaction path) so
|
|
549
|
+
* the identity seam (identity/binding.ts bindDiscord) resolves the real guild/channel/user โ
|
|
550
|
+
* workspace/surface/mode/visibility. Drops bot authors: we never act on another bot (incl.
|
|
551
|
+
* ourselves) โ mirrors discord.ts's authFromInteraction and the relay's own bot filter.
|
|
552
|
+
*/
|
|
553
|
+
export function authForRelayMessage(msg: DiscordRelayMessage): OpenEverywhereDiscordAuth | null {
|
|
554
|
+
if (msg.author.bot) {
|
|
555
|
+
// The ONE narrow exception (item 29, e2e driver bot): a guild-pinned, env-allowlisted
|
|
556
|
+
// driver bot is treated as a user so full-path integration scenarios can drive the
|
|
557
|
+
// real agent. Fail-closed default-off (no env โ unconditional drop, exactly as
|
|
558
|
+
// before); every acceptance is logged so an armed seam is never invisible.
|
|
559
|
+
if (!isAllowedDriver(msg.guildId, msg.author.id)) return null;
|
|
560
|
+
log(`e2e driver accepted as user: bot ${msg.author.id} in guild ${msg.guildId} (OE_TEST_DRIVER_IDS)`);
|
|
561
|
+
}
|
|
562
|
+
return buildDiscordAuth({
|
|
563
|
+
userId: msg.author.id,
|
|
564
|
+
guildId: msg.guildId,
|
|
565
|
+
channelId: msg.channelId,
|
|
566
|
+
channelType: msg.channelType,
|
|
567
|
+
// OPE-60: demote a pending (membership-screening-incomplete) member to external. Only set
|
|
568
|
+
// when the relay forwarded the member object; undefined otherwise (unchanged behaviour).
|
|
569
|
+
memberPending: msg.memberPending,
|
|
570
|
+
// OPE-671: the โ stop-hand anchor โ see DiscordIdentityInput.messageId's doc.
|
|
571
|
+
messageId: msg.id,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Is this bot-authored relay message's author id a KNOWN e2e-driver id (OPE-647) โ pinned to
|
|
577
|
+
* SOME guild in `OE_TEST_DRIVER_IDS` โ but NOT to this message's own guild? `authForRelayMessage`
|
|
578
|
+
* already refuses the message either way: `isAllowedDriver` is guild-pinned by format
|
|
579
|
+
* (`guildId:botUserId`), so a driver id posting outside its pinned guild never passes. This
|
|
580
|
+
* classifier exists only to tell that refusal APART from routine "some unrelated real bot
|
|
581
|
+
* posted" noise, so a driver credential surfacing in the wrong guild gets a NAMED audit row
|
|
582
|
+
* instead of vanishing into the ordinary bot-drop silence. Pure โ no I/O โ so every branch is
|
|
583
|
+
* unit-tested against a hand-built allowlist, with no live Discord and no store.
|
|
584
|
+
*/
|
|
585
|
+
export function isMisplacedDriver(
|
|
586
|
+
guildId: string | undefined,
|
|
587
|
+
botUserId: string,
|
|
588
|
+
allowlist: ReadonlySet<string> = parseDriverAllowlist(process.env.OE_TEST_DRIVER_IDS),
|
|
589
|
+
): boolean {
|
|
590
|
+
if (!botUserId) return false;
|
|
591
|
+
if (guildId !== undefined && allowlist.has(`${guildId}:${botUserId}`)) return false; // pinned HERE โ ordinary admission, not a misplacement
|
|
592
|
+
for (const entry of allowlist) {
|
|
593
|
+
if (entry.endsWith(`:${botUserId}`)) return true; // pinned to a DIFFERENT guild
|
|
594
|
+
}
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The AUTHOR-TRUST question for this message (OPE-516) โ the operator's own declaration of who is
|
|
600
|
+
* internal in this guild, which is the ONLY author-grain evidence Discord can produce. Discord has
|
|
601
|
+
* no organisation concept, so `discordTrust` can never refute an author; `member.pending` is a
|
|
602
|
+
* SCREENING verdict, not a membership one. The register fills that hole from reviewed config.
|
|
603
|
+
*
|
|
604
|
+
* Returns `{}` for a DM: a DM has no guild, so there is no tenant to arm and no roles to hold. The
|
|
605
|
+
* DM's trust question is about the PARTNER and is answered by the surface register instead โ the
|
|
606
|
+
* same split `ChatWakeRoom.dm` makes on the room leg. A spread-able fragment rather than a
|
|
607
|
+
* `| undefined` value so the call sites stay one expression and an absent author is byte-identical
|
|
608
|
+
* to before this existed.
|
|
609
|
+
*
|
|
610
|
+
* The tenant key is built from the guild id and NOTHING ELSE. The platform and tenant halves of a
|
|
611
|
+
* principal key are surface facts, never properties of the id (identity/principal-key.ts) โ and a
|
|
612
|
+
* snowflake is a bare digit string that would collide happily across vendors if it were sniffed.
|
|
613
|
+
*/
|
|
614
|
+
export function authorFacts(msg: DiscordRelayMessage): { readonly authorTrust?: AuthorTrustQuery } {
|
|
615
|
+
if (!msg.guildId) return {};
|
|
616
|
+
return {
|
|
617
|
+
authorTrust: {
|
|
618
|
+
tenantKey: `discord/${msg.guildId}`,
|
|
619
|
+
authorId: msg.author.id,
|
|
620
|
+
// The membership keys this author holds, in the group-rung grammar the register's patterns
|
|
621
|
+
// are written in (identity/surface-key-grammar.ts). Empty when the relay sent no member
|
|
622
|
+
// object โ no evidence, which the register reads as "matched nothing".
|
|
623
|
+
memberships: (msg.memberRoles ?? []).map((roleId) => `discord/${msg.guildId}/role:${roleId}`),
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* THE EXTERNAL-PARTICIPANT VERDICT for one Discord message (OPE-507 / OPE-504) โ pure, so the four
|
|
630
|
+
* outcomes are tested without a REST call, a store or a live relay. `dispatchTurn` calls it once and
|
|
631
|
+
* switches on the answer.
|
|
632
|
+
*
|
|
633
|
+
* The four values name WHAT THE CALLER OWES, which is the only thing that differs between them:
|
|
634
|
+
* `proceed` โ nothing suppressed. The shipped grains always return this.
|
|
635
|
+
* `withheld-addressed` โ an excluded author @mentioned or DM'd. Owed a PRIVATE notice, nothing else.
|
|
636
|
+
* `withheld-ambient` โ an excluded author spoke ambiently. Owed NOTHING; suppress in silence.
|
|
637
|
+
* `silent-room` โ the room is classified external and the operator asked for silence. Owed
|
|
638
|
+
* the once-ever suppressed-wake announcement.
|
|
639
|
+
*
|
|
640
|
+
* ๐ด THE AUTHOR GRAIN IS TESTED FIRST, AND THE ORDER MATTERS. An excluded author in a silenced room
|
|
641
|
+
* must get the AUTHOR outcome: they are owed a notice they can act on ("ask someone inside"), where
|
|
642
|
+
* the room announcement tells the whole room something about the room. Reversing this would answer
|
|
643
|
+
* the wrong question to the wrong audience.
|
|
644
|
+
*/
|
|
645
|
+
export type DiscordExternalVerdict = "proceed" | "withheld-addressed" | "withheld-ambient" | "silent-room";
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* The resolved external-participant policy for one room: the ORG DEFAULT, with the DOMAIN OVERRIDE
|
|
649
|
+
* applied when the domain registry can be read.
|
|
650
|
+
*
|
|
651
|
+
* ๐ด AN UNREADABLE DOMAIN REGISTRY DEGRADES TO THE ORG DEFAULT, IT DOES NOT THROW. Every other
|
|
652
|
+
* `getConfig()` call on this path sits BELOW the DM early-return, so a DM never touched the config
|
|
653
|
+
* registry before this feature existed; making the policy the first thing that does would let an
|
|
654
|
+
* unregistered config take DMs off the air. "Could not read the domain registry" means "no domain
|
|
655
|
+
* override", which resolves to the shipped org default โ today's behaviour, which is the only safe
|
|
656
|
+
* answer. A genuinely broken config is still loud everywhere else it is read.
|
|
657
|
+
*/
|
|
658
|
+
function externalPolicyForRoom(roomKey: string): ExternalPolicy {
|
|
659
|
+
try {
|
|
660
|
+
return resolveExternalPolicy(externalPolicyOverrideFor(roomKey, getConfig().loadDomains()));
|
|
661
|
+
} catch {
|
|
662
|
+
return resolveExternalPolicy();
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function discordExternalVerdict(input: {
|
|
667
|
+
readonly policy: ExternalPolicy;
|
|
668
|
+
readonly author: IngestedChatMessage;
|
|
669
|
+
/** The message @mentions the bot, or arrived in a DM โ an ADDRESSED message is owed an answer. */
|
|
670
|
+
readonly addressed: boolean;
|
|
671
|
+
readonly isDm: boolean;
|
|
672
|
+
/** The DOMAIN-grain surface key (a thread asks its parent channel), for the room classification. */
|
|
673
|
+
readonly roomKey: string;
|
|
674
|
+
}): DiscordExternalVerdict {
|
|
675
|
+
if (withholdsBody(input.author, input.policy)) return input.addressed ? "withheld-addressed" : "withheld-ambient";
|
|
676
|
+
// A DM contributes NO room leg โ on a DM the trust plane asks about the PARTNER, not the room.
|
|
677
|
+
const room = input.isDm ? { dm: true } : { surfaceKey: input.roomKey };
|
|
678
|
+
return roomIsSilenced({ room, ingested: [input.author] }, input.policy) ? "silent-room" : "proceed";
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Build the BindableContext the identity/partition seam expects from a dispatch auth. */
|
|
682
|
+
function ctxFromAuth(auth: OpenEverywhereDiscordAuth): BindableContext {
|
|
683
|
+
return { channel: { kind: "discord" }, session: { auth: { current: auth } } };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Is this channel type a THREAD? Discord 10 (announcement thread) / 11 (public thread) /
|
|
688
|
+
* 12 (private thread) โ the surfaces whose channel id is NOT the id domains and spend
|
|
689
|
+
* caps are declared against (those name the PARENT channel). Pure; drives the
|
|
690
|
+
* parent-grain stamping in dispatchTurn (item 22).
|
|
691
|
+
*/
|
|
692
|
+
export function isThreadChannelType(channelType: string | undefined): boolean {
|
|
693
|
+
return channelType === "10" || channelType === "11" || channelType === "12";
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Is this message a direct message to the bot? Discord channel type 1 (DM) / 3 (group DM).
|
|
698
|
+
* A DM is BY DEFINITION addressed to the bot โ there is no one else it could be for โ so it
|
|
699
|
+
* wakes without a mention and its text is a direct instruction (no judged preamble), exactly
|
|
700
|
+
* like a Slack DM. Pure.
|
|
701
|
+
*/
|
|
702
|
+
export function isDmChannel(channelType: string | undefined): boolean {
|
|
703
|
+
return channelType === "1" || channelType === "3";
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Should this ambient message wake the agent? A DM always wakes (it's inherently addressed to
|
|
708
|
+
* the bot). An @mention is a direct address (guaranteed reply, like a Slack mention); otherwise
|
|
709
|
+
* the channel must already be ENGAGED โ the agent was pulled in earlier and keeps following
|
|
710
|
+
* without re-tagging (engagement.ts, the same registry Slack uses). A non-mention message in a
|
|
711
|
+
* non-engaged guild channel is dropped, so the bot stays quiet in channels it wasn't invited
|
|
712
|
+
* into. Pure โ unit-tested without the operational store.
|
|
713
|
+
*/
|
|
714
|
+
export function shouldDispatchDiscord(input: {
|
|
715
|
+
readonly mentionsBot: boolean;
|
|
716
|
+
readonly isEngaged: boolean;
|
|
717
|
+
readonly isDm: boolean;
|
|
718
|
+
}): boolean {
|
|
719
|
+
return input.isDm || input.mentionsBot || input.isEngaged;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Engagement is per-conversation. A Discord thread is its OWN channel id, so the channel id is
|
|
723
|
+
// the right engagement key: the partition surfaceKey already includes it, and using it as the
|
|
724
|
+
// registry's per-thread key keeps threads and their parent channel independently engaged.
|
|
725
|
+
function engagementKey(msg: DiscordRelayMessage): string {
|
|
726
|
+
return msg.channelId;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Is this message a NAME-DROP candidate โ Slack's nameDropWake brought to Discord (item 25;
|
|
731
|
+
* live 2026-07-06 Tommy addressed the bot by display name and it only "worked" where the
|
|
732
|
+
* channel happened to be engaged)? Pure pre-filter, cheapest checks first; the caller still
|
|
733
|
+
* gates on the domain's `nameDropWake` flag (config, evaluated at DOMAIN grain โ a thread
|
|
734
|
+
* checks its parent channel's domain). Disqualifiers:
|
|
735
|
+
* - mention / DM / engaged โ those wake paths own the message already;
|
|
736
|
+
* - a bot author โ never self/bot-trigger;
|
|
737
|
+
* - anything but a guild conversation surface (text 0, announcement 5, threads 10/11/12).
|
|
738
|
+
* DELIBERATE Slack DEVIATION: Slack excludes thread replies ("the engagement path owns
|
|
739
|
+
* threads") but on Discord threads ARE the primary conversation surface and an
|
|
740
|
+
* UN-engaged thread (TTL'd out, or human-created) that says the bot's name is a clear
|
|
741
|
+
* address with no other wake path โ the exact live gap. Forum containers (15) and
|
|
742
|
+
* unknown types stay excluded, fail-safe;
|
|
743
|
+
* - no relayed bot name (older relay) โ fail-closed to mention/engagement-only, so
|
|
744
|
+
* `identity.name`/`wake.nameDrop.include` alone never fires this without a live name too.
|
|
745
|
+
* Unlike Slack there is NO externally-shared-channel probe: Discord has no shared-channel
|
|
746
|
+
* concept โ guild membership IS the boundary, so a guild surface is internal by construction.
|
|
747
|
+
* The text is matched against the UNIFIED wake token set (OPE-571): `input.botName` (the live
|
|
748
|
+
* Discord display name) UNION `identity.name`, extended by `wake.nameDrop.include` and narrowed
|
|
749
|
+
* by `wake.nameDrop.exclude` โ see {@link wakeNameDropTokens}, the same builder Slack's
|
|
750
|
+
* `buildWakeTriggers` uses, so the two surfaces' wake set stays one rule.
|
|
751
|
+
*/
|
|
752
|
+
export function nameDropEligible(input: {
|
|
753
|
+
readonly mentionsBot: boolean;
|
|
754
|
+
readonly isDm: boolean;
|
|
755
|
+
readonly isEngaged: boolean;
|
|
756
|
+
readonly authorIsBot: boolean;
|
|
757
|
+
readonly channelType?: string;
|
|
758
|
+
readonly content: string;
|
|
759
|
+
readonly botName?: string;
|
|
760
|
+
}): boolean {
|
|
761
|
+
if (input.mentionsBot || input.isDm || input.isEngaged) return false;
|
|
762
|
+
if (input.authorIsBot) return false;
|
|
763
|
+
const t = input.channelType;
|
|
764
|
+
if (t !== "0" && t !== "5" && t !== "10" && t !== "11" && t !== "12") return false;
|
|
765
|
+
if (!input.botName) return false;
|
|
766
|
+
return wakeNameDropTokens(input.botName).some((token) => nameDropPattern(token).test(input.content));
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* The name-drop road's audit key for one Discord message (OPE-1899) โ the partition every other
|
|
771
|
+
* Discord row is keyed by, plus the two ids a reader joins on. Built here rather than at the two
|
|
772
|
+
* call sites so the ADMIT row and the SILENT row can never key differently.
|
|
773
|
+
* ๐ด `surface` IS THE WHOLE `surfaceKey`, NOT THE BARE CHANNEL ID, because that is what every other
|
|
774
|
+
* audit row this file writes carries (`discord.external_policy_suppressed`, the spend gate, the
|
|
775
|
+
* dispatch row). An operator answering "is this road dead in that room" filters by that value, so a
|
|
776
|
+
* row keyed any other way is a row their query cannot see.
|
|
777
|
+
* Discord's messages carry no separate timestamp on these facts, so the message's own id IS its
|
|
778
|
+
* surface locator โ the same value the โ anchor and the reaction swap address a message by.
|
|
779
|
+
*/
|
|
780
|
+
function nameDropAuditKey(
|
|
781
|
+
partition: Partition,
|
|
782
|
+
msg: { readonly channelId: string; readonly id: string; readonly author: { readonly id: string } },
|
|
783
|
+
): NameDropAuditKey {
|
|
784
|
+
return {
|
|
785
|
+
org: partition.org,
|
|
786
|
+
platform: partition.platform,
|
|
787
|
+
workspace: partition.workspace,
|
|
788
|
+
surface: partition.surfaceKey,
|
|
789
|
+
principalId: msg.author.id,
|
|
790
|
+
container: msg.channelId,
|
|
791
|
+
ts: msg.id,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// --- turn message -----------------------------------------------------------
|
|
796
|
+
//
|
|
797
|
+
// (The old renderTurn / renderNameDropTurn hand-rolled prose renderers were RETIRED in the
|
|
798
|
+
// NormalizedWake migration โ a design note commit 3. The native dispatch
|
|
799
|
+
// now builds a {@link discordNativeWake} and renders it through the ONE shared renderWake, so
|
|
800
|
+
// Discord's judged/name-drop wakes carry the canonical Slack-parity preamble + the full wake
|
|
801
|
+
// envelope instead of a drifted prose stub. Direct DM/@mention bodies still pass through verbatim,
|
|
802
|
+
// via renderWake's `direct` addressing โ W4.)
|
|
803
|
+
|
|
804
|
+
// Bot-auth header shared by every REST call below. `Bot <token>` is Discord's scheme; the token
|
|
805
|
+
// is a SECRET โ it rides in the header, NEVER in a log line or error string. Returns null when
|
|
806
|
+
// there is no token, so each caller degrades to today's behaviour (silence) rather than throwing.
|
|
807
|
+
function botAuthHeaders(token: string): Record<string, string> {
|
|
808
|
+
return { authorization: `Bot ${token}`, "content-type": "application/json" };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// --- the gateway's 429 backoff (OPE-1311) -----------------------------------
|
|
812
|
+
//
|
|
813
|
+
// Every REST write below goes through `discordFetch` (discord-rest.ts), the ONE Discord write funnel,
|
|
814
|
+
// so a rate-limited gateway write honours the delay Discord names โ in the JSON `retry_after` body
|
|
815
|
+
// field, which wins over the header. OPE-1236 put that funnel over the reply seam and the message
|
|
816
|
+
// ops; this file was out of that ticket's scope and failed on Discord's FIRST refusal.
|
|
817
|
+
//
|
|
818
|
+
// ๐ด EXHAUSTION IS LOGGED BY NAME, THEN SWALLOWED. Every write here is fail-silent by design: the
|
|
819
|
+
// turn already ran, and no failure arm may throw into the caller. So a `RateLimitExhaustedError`
|
|
820
|
+
// would leave NO trace at all โ the silent drop the backoff exists to prevent. Each catch below
|
|
821
|
+
// therefore names it before swallowing it, the posture `slackForm` and the wake-backscroll fetcher
|
|
822
|
+
// take (OPE-1211). Every field on that error is token-free and id-free, so the line is safe to log.
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* The tighter policy the two EPHEMERAL status affordances use โ typing and the ๐ reaction.
|
|
826
|
+
*
|
|
827
|
+
* ๐ด THE SHARED DEFAULTS ARE WRONG FOR THESE TWO, AND THE REASON IS LATENCY, NOT CORRECTNESS. Both
|
|
828
|
+
* are awaited INLINE on the turn's own path: `triggerDiscordTyping` runs inside the streaming loop
|
|
829
|
+
* and the reaction is set before the turn starts. The shared 4-attempt / 30 s-per-wait / 60 s-budget
|
|
830
|
+
* policy would stall a live turn for up to a minute to deliver a typing indicator that Discord
|
|
831
|
+
* expires after 10 seconds โ a worse outcome than dropping the affordance, which is exactly what the
|
|
832
|
+
* code did before this backoff existed. So they get one retry and a 2 s ceiling, and give up.
|
|
833
|
+
*
|
|
834
|
+
* This mirrors `slack-web-api.ts`, which passes the same shape for the `auth.test` identity probe on
|
|
835
|
+
* Slack's 3-second event-ack path, and the reason `RetryPolicy` exists as a caller-settable shape.
|
|
836
|
+
* The writes that must LAND โ the completion post-back, the open-DM, the thread open โ keep the
|
|
837
|
+
* shared defaults.
|
|
838
|
+
*
|
|
839
|
+
* ๐ด IT IS SPREAD LAST, SO A CALLER CANNOT WIDEN IT. The two functions merge it OVER `deps.retry`, not
|
|
840
|
+
* under it. A test may still inject a `sleep` or a clock โ this policy names neither โ but nothing can
|
|
841
|
+
* hand the typing indicator the 60 s budget, which is the whole point of it having its own policy.
|
|
842
|
+
*/
|
|
843
|
+
const AFFORDANCE_RETRY: RetryPolicy = { maxAttempts: 2, capMs: 2_000, totalBudgetMs: 2_000 };
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Low-level create-message: `POST /channels/{id}/messages` with the bot token. Returns the NEW
|
|
847
|
+
* message's id on success (needed by the holding-message flow so completion can edit it), or null
|
|
848
|
+
* on any failure โ no token, non-ok status, network error, or a response missing an id. NEVER
|
|
849
|
+
* throws. postDiscordReply + postHoldingMessage are both built on this so the auth/error handling
|
|
850
|
+
* lives in one place (the bot token is a SECRET โ never logged).
|
|
851
|
+
*/
|
|
852
|
+
export async function discordCreateMessage(channelId: string, content: string, deps: DiscordFetchDeps = {}): Promise<string | null> {
|
|
853
|
+
const token = process.env.DISCORD_BOT_TOKEN;
|
|
854
|
+
if (!token) {
|
|
855
|
+
log(`DISCORD_BOT_TOKEN is not set โ cannot post to channel ${channelId}`);
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
if (!channelId) {
|
|
859
|
+
log("no channel id โ nothing to post to");
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
const res = await discordFetch(
|
|
864
|
+
"POST /channels/{id}/messages",
|
|
865
|
+
`https://discord.com/api/v10/channels/${channelId}/messages`,
|
|
866
|
+
{
|
|
867
|
+
method: "POST",
|
|
868
|
+
headers: botAuthHeaders(token),
|
|
869
|
+
body: JSON.stringify({ content }),
|
|
870
|
+
},
|
|
871
|
+
deps,
|
|
872
|
+
);
|
|
873
|
+
if (!res.ok) {
|
|
874
|
+
log(`Discord create-message failed (${res.status}) for channel ${channelId}`);
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
const body = (await res.json()) as unknown;
|
|
878
|
+
return isObject(body) ? (str(body["id"]) ?? null) : null;
|
|
879
|
+
} catch (err) {
|
|
880
|
+
// The post-back is fail-silent, so an exhausted 429 retry must still leave a trace โ see the
|
|
881
|
+
// backoff note above. The error's own message names the attempts and the total wait.
|
|
882
|
+
if (err instanceof RateLimitExhaustedError) {
|
|
883
|
+
log(`Discord create-message rate limited for channel ${channelId}: gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
log(`Discord create-message error for channel ${channelId}: ${(err as Error)?.message ?? String(err)}`);
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Post the agent's reply to a Discord channel via REST. Best-effort per chunk: a failed chunk is
|
|
893
|
+
* logged (with status, never the token) and the rest still attempt. No DISCORD_BOT_TOKEN โ each
|
|
894
|
+
* chunk logs and no-ops (the turn already ran; silence is the only degradation). This is the plain
|
|
895
|
+
* post-back used when status UX is off, and the fail-open fallback when the holding-message flow
|
|
896
|
+
* couldn't run.
|
|
897
|
+
*/
|
|
898
|
+
/**
|
|
899
|
+
* Open (or re-open) the 1:1 DM channel with one user and return its id โ `POST /users/@me/channels`.
|
|
900
|
+
* Idempotent on Discord's side: it returns the EXISTING DM channel when one is already open, so
|
|
901
|
+
* calling it repeatedly creates nothing.
|
|
902
|
+
*
|
|
903
|
+
* It exists for ONE caller: the external-sender notice (OPE-507 behaviour 1), which owes the sender a
|
|
904
|
+
* PRIVATE message. Fail-soft to `null` โ see {@link postExternalSenderNotice} for why a failure here
|
|
905
|
+
* must stay silent rather than fall back to the channel.
|
|
906
|
+
*/
|
|
907
|
+
export async function openDiscordDm(userId: string, deps: DiscordFetchDeps = {}): Promise<string | null> {
|
|
908
|
+
const token = process.env.DISCORD_BOT_TOKEN;
|
|
909
|
+
if (!token) return null;
|
|
910
|
+
try {
|
|
911
|
+
const res = await discordFetch(
|
|
912
|
+
"POST /users/@me/channels",
|
|
913
|
+
"https://discord.com/api/v10/users/@me/channels",
|
|
914
|
+
{
|
|
915
|
+
method: "POST",
|
|
916
|
+
headers: { ...botAuthHeaders(token), "content-type": "application/json" },
|
|
917
|
+
body: JSON.stringify({ recipient_id: userId }),
|
|
918
|
+
},
|
|
919
|
+
deps,
|
|
920
|
+
);
|
|
921
|
+
if (!res.ok) return null;
|
|
922
|
+
const body = (await res.json()) as { id?: unknown };
|
|
923
|
+
return typeof body.id === "string" ? body.id : null;
|
|
924
|
+
} catch (err) {
|
|
925
|
+
// ๐ด THIS CATCH USED TO BE BARE, AND THAT IS WHY THE NAME MATTERS MOST HERE. Every other failure
|
|
926
|
+
// arm on this path already logs; a swallowed `RateLimitExhaustedError` here would be the ONE
|
|
927
|
+
// Discord write whose rate-limit give-up left no trace anywhere. The notice this DM carries is
|
|
928
|
+
// never said elsewhere (see `postExternalSenderNotice`), so the drop is otherwise invisible.
|
|
929
|
+
// `userId` is a Discord snowflake, not personal data, and the error carries no token.
|
|
930
|
+
if (err instanceof RateLimitExhaustedError) {
|
|
931
|
+
log(`Discord open-DM rate limited for user ${userId}: gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
|
|
932
|
+
}
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Tell an EXCLUDED sender, PRIVATELY, that the agent will not act on their message (OPE-507
|
|
939
|
+
* behaviour 1). Best-effort and silent on failure.
|
|
940
|
+
*
|
|
941
|
+
* ๐ด THIS NOTICE NEVER GOES TO THE GUILD CHANNEL, AND THAT RULING STANDS. Its text names the
|
|
942
|
+
* organisation, so posting it where the room can read it would announce that this person is
|
|
943
|
+
* classified as an outsider โ a disclosure about a member of the room, not a notice to the sender.
|
|
944
|
+
* If the DM cannot be opened (the sender blocks bot DMs, the token is absent, Discord refuses),
|
|
945
|
+
* THIS notice is not said anywhere.
|
|
946
|
+
*
|
|
947
|
+
* ๐ด THE `false` RETURN IS LOAD-BEARING (OPE-885). An EXPLICITLY ADDRESSED wake this notice could
|
|
948
|
+
* not reach falls back to the channel's fixed `EXTERNAL_MENTION_ACK` โ a different string precisely
|
|
949
|
+
* because it discloses nothing. So the caller needs the truth about delivery, and every failure arm
|
|
950
|
+
* answers `false`.
|
|
951
|
+
*/
|
|
952
|
+
async function postExternalSenderNotice(userId: string): Promise<boolean> {
|
|
953
|
+
const dmId = await openDiscordDm(userId);
|
|
954
|
+
if (!dmId) return false;
|
|
955
|
+
return (await discordCreateMessage(dmId, EXTERNAL_SENDER_NOTICE)) !== null;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Post the suppressed-wake half of the silent-room announcement pair (OPE-504) โ AT MOST ONCE PER
|
|
960
|
+
* ROOM, EVER.
|
|
961
|
+
*
|
|
962
|
+
* ๐ด DISCORD POSTS THIS HALF AND NOT THE JOIN HALF, AND THAT IS SETTLED (OPE-525). The relay forwards
|
|
963
|
+
* MESSAGES, not guild/channel join events, so there is no trigger the join notice could hang on. A
|
|
964
|
+
* Discord room is therefore announced ONCE โ at the first wake the classification silences โ rather
|
|
965
|
+
* than twice. The ruling the pair encodes is "at most twice, never periodic"; one is inside it.
|
|
966
|
+
*
|
|
967
|
+
* ๐ด THE CLAIM IS DURABLE, NOT PER-PROCESS. `claimNotice` writes a permanent row; an in-memory
|
|
968
|
+
* marker would re-announce on every cold start, and a cold start happens per serverless invocation.
|
|
969
|
+
* That would be the periodic announcement the design explicitly rejects, wearing a "once" label.
|
|
970
|
+
*
|
|
971
|
+
* FAIL-CLOSED on the claim, which is the inverse of `postOnce`'s posture and deliberate: if the store
|
|
972
|
+
* cannot answer, the agent stays QUIET. A duplicate here is not a lost message, it is the exact
|
|
973
|
+
* repetition this pair exists to avoid, so the safe direction is silence.
|
|
974
|
+
*/
|
|
975
|
+
async function announceSilentRoom(surfaceKey: string, channelId: string): Promise<boolean> {
|
|
976
|
+
const org = deploymentOrg();
|
|
977
|
+
if (!org) return false;
|
|
978
|
+
try {
|
|
979
|
+
const won = await getOperationalStore().claimNotice(org, silentNoticeKey(surfaceKey, "suppressed-wake"), new Date().toISOString());
|
|
980
|
+
if (!won) return false; // already said, once, forever
|
|
981
|
+
} catch {
|
|
982
|
+
return false; // cannot prove it is unsaid โ stay quiet
|
|
983
|
+
}
|
|
984
|
+
await discordCreateMessage(channelId, silentRoomNotice("suppressed-wake"));
|
|
985
|
+
return true;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async function postDiscordReply(channelId: string, text: string): Promise<void> {
|
|
989
|
+
for (const chunk of splitForDiscord(text)) {
|
|
990
|
+
if (!chunk.trim()) continue; // belt-and-braces: never post an invisible empty message
|
|
991
|
+
await discordCreateMessage(channelId, chunk);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Fire Discord's NATIVE typing indicator: `POST /channels/{id}/typing` โ "<bot> is typingโฆ"
|
|
997
|
+
* shown to everyone in the channel for ~10s. This is the working signal while a turn runs โ
|
|
998
|
+
* chosen over a posted "holding" message because it's the idiom Discord users already read
|
|
999
|
+
* as "working", it involves NO message (so no "(edited)" tag on the final reply and no
|
|
1000
|
+
* delete-and-repost dance), and it EXPIRES BY ITSELF โ nothing to track across turns, which
|
|
1001
|
+
* is what makes it immune to the resumed-session staleness class (see the state comment).
|
|
1002
|
+
* Re-triggered from turn/tool event handlers to cover long turns. Purely an affordance:
|
|
1003
|
+
* best-effort, every failure swallowed, missing token no-ops, NEVER throws.
|
|
1004
|
+
*/
|
|
1005
|
+
// Throttle for the HIGH-FREQUENCY typing triggers (the streamed *.appended events, which can
|
|
1006
|
+
// fire many times a second): re-POST only when the previous trigger is old enough that the
|
|
1007
|
+
// ~10s indicator is nearing expiry. In-process state โ if an event handler lands in a fresh
|
|
1008
|
+
// invocation the map is cold and we simply POST again (an extra cheap call, never a miss).
|
|
1009
|
+
const lastTypingAt = new Map<string, number>();
|
|
1010
|
+
const TYPING_REFRESH_MS = 7_000;
|
|
1011
|
+
|
|
1012
|
+
/** Test hook: drop every recorded throttle timestamp. Production correctness needs this
|
|
1013
|
+
* map to live for the whole process (the throttle is meant to span turns), but the
|
|
1014
|
+
* golden-transcript scenario harness (discord-scenario-harness.ts) runs many scenarios in ONE
|
|
1015
|
+
* process and reuses small deterministic channel/thread ids ("TH1", โฆ) across them โ so
|
|
1016
|
+
* without a reset, whether a later scenario's typing throttle "holds" depends on the
|
|
1017
|
+
* REAL wall-clock gap since an earlier, unrelated scenario last wrote the same key: fast
|
|
1018
|
+
* test execution (<7s between scenarios) silently suppresses a typing re-trigger the
|
|
1019
|
+
* golden doesn't expect to be suppressed; a slow/loaded run (>7s) doesn't, and the extra
|
|
1020
|
+
* POST diverges the golden โ a genuine flake pinned as OPE-69 (confirmed by forcing an
|
|
1021
|
+
* 8s real delay between scenarios 01/02, which flipped the throttle and diverged
|
|
1022
|
+
* 02-thread-followup-resume). Called by ScenarioRunner.scenario() before every scenario
|
|
1023
|
+
* so each starts cold, exactly like clearDiscordInfoCacheForTest(). */
|
|
1024
|
+
export function clearTypingThrottleForTest(): void {
|
|
1025
|
+
lastTypingAt.clear();
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** True when a typing re-trigger is due for this channel (and records it as fired). Pure
|
|
1029
|
+
* given an injected clock โ unit-tested; the default clock is Date.now. */
|
|
1030
|
+
export function typingRefreshDue(
|
|
1031
|
+
channelId: string,
|
|
1032
|
+
nowMs: number = Date.now(),
|
|
1033
|
+
state: Map<string, number> = lastTypingAt,
|
|
1034
|
+
): boolean {
|
|
1035
|
+
const last = state.get(channelId);
|
|
1036
|
+
if (last !== undefined && nowMs - last < TYPING_REFRESH_MS) return false;
|
|
1037
|
+
state.set(channelId, nowMs);
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Judged-wake typing gate (live 2026-07-07): engaged and name-drop wakes run a JUDGED turn
|
|
1042
|
+
// that may end in the NO_REPLY sentinel โ nothing gets posted. Firing typing at dispatch for
|
|
1043
|
+
// those turns produced "typingโฆ for ~10s, then nothing" (Tommy's "test" in an engaged
|
|
1044
|
+
// channel), indistinguishable from a crash. Discord typing can only be triggered, never
|
|
1045
|
+
// cancelled โ so the only lever is to NOT start it until the model has proven it's replying:
|
|
1046
|
+
// the streamed message text deviating from the sentinel. Addressed wakes (mention/DM) always
|
|
1047
|
+
// answer โ they keep dispatch-time typing. In-process, keyed by reply channel id, fail-OPEN:
|
|
1048
|
+
// a cold map (event handler in a fresh invocation) just behaves like today โ typing during
|
|
1049
|
+
// judgment โ never a lost reply or a stuck indicator (the indicator expires by itself).
|
|
1050
|
+
const closedTypingGates = new Set<string>();
|
|
1051
|
+
|
|
1052
|
+
/** Close the gate for a judged wake: hold all typing until the reply is proven. */
|
|
1053
|
+
export function closeTypingGate(channelId: string, gates: Set<string> = closedTypingGates): void {
|
|
1054
|
+
gates.add(channelId);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Open (clear) the gate โ addressed dispatch, reply proven, or turn over. */
|
|
1058
|
+
export function openTypingGate(channelId: string, gates: Set<string> = closedTypingGates): void {
|
|
1059
|
+
gates.delete(channelId);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
export function typingGateOpen(channelId: string, gates: Set<string> = closedTypingGates): boolean {
|
|
1063
|
+
return !gates.has(channelId);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Feed the streamed assistant text into the gate; returns whether typing may fire. Mirrors
|
|
1068
|
+
* slack-events.ts isNoReply EXACTLY (trimStart + case-sensitive startsWith): while the text
|
|
1069
|
+
* is still a prefix of the sentinel the verdict is UNDECIDED (hold); once it starts with the
|
|
1070
|
+
* full sentinel it IS a judged silence (hold โ the final reply will be swallowed); any
|
|
1071
|
+
* deviation proves a real reply, so the gate opens and stays open for the rest of the turn.
|
|
1072
|
+
*/
|
|
1073
|
+
export function typingGateFeed(
|
|
1074
|
+
channelId: string,
|
|
1075
|
+
messageSoFar: string,
|
|
1076
|
+
gates: Set<string> = closedTypingGates,
|
|
1077
|
+
): boolean {
|
|
1078
|
+
if (!gates.has(channelId)) return true;
|
|
1079
|
+
const t = messageSoFar.trimStart();
|
|
1080
|
+
if (t.startsWith(NO_REPLY_SENTINEL) || NO_REPLY_SENTINEL.startsWith(t)) return false;
|
|
1081
|
+
gates.delete(channelId);
|
|
1082
|
+
return true;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
export async function triggerDiscordTyping(channelId: string, deps: DiscordFetchDeps = {}): Promise<void> {
|
|
1086
|
+
const token = process.env.DISCORD_BOT_TOKEN;
|
|
1087
|
+
if (!token || !channelId) return;
|
|
1088
|
+
try {
|
|
1089
|
+
const res = await discordFetch(
|
|
1090
|
+
"POST /channels/{id}/typing",
|
|
1091
|
+
`https://discord.com/api/v10/channels/${channelId}/typing`,
|
|
1092
|
+
{ method: "POST", headers: { authorization: `Bot ${token}` } },
|
|
1093
|
+
{ ...deps, retry: { ...deps.retry, ...AFFORDANCE_RETRY } },
|
|
1094
|
+
);
|
|
1095
|
+
if (!res.ok) {
|
|
1096
|
+
log(`Discord typing trigger failed (${res.status}) for channel ${channelId}`);
|
|
1097
|
+
}
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
// The typing indicator is an affordance on the turn's own latency path โ it gives up fast (see
|
|
1100
|
+
// AFFORDANCE_RETRY) and the give-up is logged, never thrown.
|
|
1101
|
+
if (err instanceof RateLimitExhaustedError) {
|
|
1102
|
+
log(`Discord typing trigger rate limited for channel ${channelId}: gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
log(`Discord typing trigger error for channel ${channelId}: ${(err as Error)?.message ?? String(err)}`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// The status emoji. encodeURIComponent yields exactly Discord's expected percent-encoding
|
|
1110
|
+
// (๐ โ %F0%9F%91%80). There is deliberately NO โ
: the convention (established on Slack โ
|
|
1111
|
+
// see status/status.ts) is ๐ = seen/working, REMOVAL = done, and โ
stays reserved for
|
|
1112
|
+
// humans to mark things themselves. The bot never plants a tick.
|
|
1113
|
+
const EYES_EMOJI = "๐";
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Add or remove the bot's own reaction on a message: `{PUT|DELETE}
|
|
1117
|
+
* /channels/{id}/messages/{messageId}/reactions/{emoji}/@me` with the emoji URL-encoded. Used to
|
|
1118
|
+
* show ๐ the moment we decide to respond and swap to โ
(or just clear ๐) on completion. Purely a
|
|
1119
|
+
* status affordance โ best-effort, any failure is logged and swallowed, NEVER throws, and a missing
|
|
1120
|
+
* token no-ops. (The bot token is a SECRET โ never logged.)
|
|
1121
|
+
*/
|
|
1122
|
+
export async function setDiscordReaction(
|
|
1123
|
+
channelId: string,
|
|
1124
|
+
messageId: string,
|
|
1125
|
+
emoji: string,
|
|
1126
|
+
action: "PUT" | "DELETE",
|
|
1127
|
+
deps: DiscordFetchDeps = {},
|
|
1128
|
+
): Promise<void> {
|
|
1129
|
+
const token = process.env.DISCORD_BOT_TOKEN;
|
|
1130
|
+
if (!token) return; // no token โ silently skip the affordance (degrades to no reaction)
|
|
1131
|
+
if (!channelId || !messageId) return;
|
|
1132
|
+
const encoded = encodeURIComponent(emoji);
|
|
1133
|
+
try {
|
|
1134
|
+
const res = await discordFetch(
|
|
1135
|
+
// The route SHAPE, id-free and emoji-free, so the named failure carries no ids โ the same
|
|
1136
|
+
// operation string discord-post.ts uses for its own reaction write.
|
|
1137
|
+
`${action} /channels/{id}/messages/{id}/reactions/{emoji}/@me`,
|
|
1138
|
+
`https://discord.com/api/v10/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`,
|
|
1139
|
+
{ method: action, headers: { authorization: `Bot ${token}` } },
|
|
1140
|
+
{ ...deps, retry: { ...deps.retry, ...AFFORDANCE_RETRY } },
|
|
1141
|
+
);
|
|
1142
|
+
// A DELETE of a reaction that was never added 404s โ harmless, so we don't treat it as notable.
|
|
1143
|
+
if (!res.ok && !(action === "DELETE" && res.status === 404)) {
|
|
1144
|
+
log(`Discord reaction ${action} failed (${res.status}) for message ${messageId} in channel ${channelId}`);
|
|
1145
|
+
}
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
// The ๐ reaction is an affordance on the turn's own latency path โ it gives up fast (see
|
|
1148
|
+
// AFFORDANCE_RETRY) and the give-up is logged, never thrown.
|
|
1149
|
+
if (err instanceof RateLimitExhaustedError) {
|
|
1150
|
+
log(`Discord reaction ${action} rate limited for channel ${channelId}: gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
log(`Discord reaction ${action} error for channel ${channelId}: ${(err as Error)?.message ?? String(err)}`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// --- auto-thread (one task = one thread) ------------------------------------
|
|
1158
|
+
// The signature Slack pattern is "mention starts a thread; the work happens in it." Discord
|
|
1159
|
+
// threads are first-class channel objects (a thread IS its own channel id), so we map the
|
|
1160
|
+
// same model: a mention in a base guild text/announcement channel spins up a thread and the
|
|
1161
|
+
// WHOLE conversation re-keys onto it (its own surface silo + durable session + engagement).
|
|
1162
|
+
// Everything below is pure/deterministic (unit-tested) except createDiscordThread's REST call.
|
|
1163
|
+
|
|
1164
|
+
/** Strip ONE leading bot-mention token (`<@123>` / `<@!123>`) โ shared by thread naming and
|
|
1165
|
+
* the stop-verb match, both of which care about the words AFTER the address. Pure. */
|
|
1166
|
+
export function stripLeadingMention(content: string): string {
|
|
1167
|
+
return content.replace(/^\s*<@!?[^>]+>\s*/, "");
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Should a mention here open a new thread? True ONLY for a base guild text (0) or announcement
|
|
1172
|
+
* (5) channel when the toggle is on. False for threads (10/11/12 โ already a thread surface),
|
|
1173
|
+
* DMs / group DMs (1/3 โ no threads), forum channels (15 โ a forum post is itself a thread;
|
|
1174
|
+
* don't nest), and any unknown type (fail-safe: keep today's inline behaviour). Pure.
|
|
1175
|
+
*/
|
|
1176
|
+
export function shouldAutoThread(channelType: string | undefined, enabled: boolean): boolean {
|
|
1177
|
+
if (!enabled) return false;
|
|
1178
|
+
return channelType === "0" || channelType === "5";
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Read the discord.autoThread toggle. Default ON โ auto-thread is the on-brand behaviour;
|
|
1183
|
+
* a deployer opts OUT of it. Only the explicit "false"/"0" disable; anything else (unset,
|
|
1184
|
+
* "true", "1", โฆ) leaves it on. `env` is injectable so the toggle is unit-tested without
|
|
1185
|
+
* touching process.env. Pure.
|
|
1186
|
+
*/
|
|
1187
|
+
export function autoThreadEnabled(env?: string): boolean {
|
|
1188
|
+
return env === undefined ? getSettings().discord.autoThread : env !== "false" && env !== "0";
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Read the discord.statusUx toggle. Default ON โ the "working" affordances (๐/โ
reaction + a
|
|
1193
|
+
* holding message edited into the reply) are on-brand, so a deployer opts OUT. Only the explicit
|
|
1194
|
+
* "false"/"0" disable; anything else (unset, "true", "1", โฆ) leaves it on. When off, the surface
|
|
1195
|
+
* behaves EXACTLY as before this feature: one plain reply post, no reaction, no holding message.
|
|
1196
|
+
* `env` is injectable so the toggle is unit-tested without touching process.env. Pure. Mirrors
|
|
1197
|
+
* autoThreadEnabled verbatim.
|
|
1198
|
+
*/
|
|
1199
|
+
export function statusUxEnabled(env?: string): boolean {
|
|
1200
|
+
return env === undefined ? getSettings().discord.statusUx : env !== "false" && env !== "0";
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Derive a Discord thread name from the triggering message. Strips a single leading bot
|
|
1205
|
+
* mention token (`<@123>` or `<@!123>`), collapses whitespace, and truncates to Discord's
|
|
1206
|
+
* 100-char cap (trimming back to a word boundary when one sits reasonably near the cut).
|
|
1207
|
+
* Falls back to `Conversation with <botName>` when nothing usable remains (e.g. a bare
|
|
1208
|
+
* mention with no text). Pure + deterministic โ no model call (v1; a cheap-model title is a
|
|
1209
|
+
* noted later enhancement).
|
|
1210
|
+
*/
|
|
1211
|
+
export function deriveThreadName(content: string, botName?: string): string {
|
|
1212
|
+
const fallback = `Conversation with ${botName ?? "the agent"}`;
|
|
1213
|
+
// Strip ONE leading mention token, then collapse all runs of whitespace to single spaces.
|
|
1214
|
+
const cleaned = stripLeadingMention(content).replace(/\s+/g, " ").trim();
|
|
1215
|
+
if (cleaned.length === 0) return fallback; // empty / mention-only โ fallback
|
|
1216
|
+
if (cleaned.length <= 100) return cleaned;
|
|
1217
|
+
// Over the cap: hard-slice to 100, then prefer a word boundary if one is close to the end
|
|
1218
|
+
// (โฅ80) so we don't cut mid-word; otherwise keep the hard slice.
|
|
1219
|
+
const hard = cleaned.slice(0, 100);
|
|
1220
|
+
const lastSpace = hard.lastIndexOf(" ");
|
|
1221
|
+
return (lastSpace >= 80 ? hard.slice(0, lastSpace) : hard).trim();
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Create a Discord thread hanging off the triggering message:
|
|
1226
|
+
* `POST /channels/{channelId}/messages/{messageId}/threads` with the bot token. Returns the
|
|
1227
|
+
* new thread's channel id on success, or null on ANY failure (no token, non-ok status,
|
|
1228
|
+
* network error, or a response missing an id). NEVER throws โ threading is fail-OPEN: a null
|
|
1229
|
+
* here degrades the turn to an inline reply, it never drops the turn. Mirrors postDiscordReply's
|
|
1230
|
+
* auth + error handling exactly (including: the bot token is a SECRET โ never logged).
|
|
1231
|
+
*/
|
|
1232
|
+
export async function createDiscordThread(
|
|
1233
|
+
channelId: string,
|
|
1234
|
+
messageId: string,
|
|
1235
|
+
name: string,
|
|
1236
|
+
deps: DiscordFetchDeps = {},
|
|
1237
|
+
): Promise<string | null> {
|
|
1238
|
+
const token = process.env.DISCORD_BOT_TOKEN;
|
|
1239
|
+
if (!token) {
|
|
1240
|
+
log(`DISCORD_BOT_TOKEN is not set โ cannot create thread on channel ${channelId}`);
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
if (!channelId || !messageId) {
|
|
1244
|
+
log("createDiscordThread: missing channel/message id โ cannot create thread");
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
try {
|
|
1248
|
+
const res = await discordFetch(
|
|
1249
|
+
"POST /channels/{id}/messages/{id}/threads",
|
|
1250
|
+
`https://discord.com/api/v10/channels/${channelId}/messages/${messageId}/threads`,
|
|
1251
|
+
{
|
|
1252
|
+
method: "POST",
|
|
1253
|
+
headers: {
|
|
1254
|
+
// `Bot <token>` is Discord's bot-auth scheme. The token is a SECRET โ never log it.
|
|
1255
|
+
authorization: `Bot ${token}`,
|
|
1256
|
+
"content-type": "application/json",
|
|
1257
|
+
},
|
|
1258
|
+
body: JSON.stringify({ name, auto_archive_duration: 1440 }),
|
|
1259
|
+
},
|
|
1260
|
+
deps,
|
|
1261
|
+
);
|
|
1262
|
+
if (!res.ok) {
|
|
1263
|
+
// 403 = missing Create Public Threads / Send Messages in Threads; 429 = rate limit; etc.
|
|
1264
|
+
log(`Discord create-thread failed (${res.status}) for channel ${channelId} โ falling back to inline`);
|
|
1265
|
+
return null;
|
|
1266
|
+
}
|
|
1267
|
+
const body = (await res.json()) as unknown;
|
|
1268
|
+
const id = isObject(body) ? str(body["id"]) : undefined;
|
|
1269
|
+
if (!id) {
|
|
1270
|
+
log(`Discord create-thread returned no thread id for channel ${channelId} โ falling back to inline`);
|
|
1271
|
+
return null;
|
|
1272
|
+
}
|
|
1273
|
+
return id;
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
// Threading is fail-OPEN: a null degrades the turn to an inline reply. An exhausted 429 is still
|
|
1276
|
+
// named, so the degradation is never a silent drop.
|
|
1277
|
+
if (err instanceof RateLimitExhaustedError) {
|
|
1278
|
+
log(`Discord create-thread rate limited for channel ${channelId}: gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms) โ falling back to inline`);
|
|
1279
|
+
return null;
|
|
1280
|
+
}
|
|
1281
|
+
log(`Discord create-thread error for channel ${channelId}: ${(err as Error)?.message ?? String(err)}`);
|
|
1282
|
+
return null;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Compute the dispatch surface for a turn, given the thread-create outcome. This is the
|
|
1288
|
+
* RE-KEY: when a thread was created (threadId non-null), the ENTIRE turn moves onto the thread
|
|
1289
|
+
* channel id โ identity/auth, continuation token, reply-target state, and engagement key all
|
|
1290
|
+
* point at the thread, so the conversation lives there (its own memory silo + durable session)
|
|
1291
|
+
* and the base channel is only where the mention landed. When no thread was created (threadId
|
|
1292
|
+
* null โ either not auto-threadable or a fail-open fallback), the turn stays on the base
|
|
1293
|
+
* channel exactly as today. Pure โ the fail-open contract is unit-tested by passing an id / null
|
|
1294
|
+
* without any live Discord call.
|
|
1295
|
+
*/
|
|
1296
|
+
export function dispatchSurfaceFor(
|
|
1297
|
+
msg: DiscordRelayMessage,
|
|
1298
|
+
baseAuth: OpenEverywhereDiscordAuth,
|
|
1299
|
+
threadId: string | null,
|
|
1300
|
+
): { auth: OpenEverywhereDiscordAuth; continuationToken: string; state: DiscordGatewayState; engagementKey: string } {
|
|
1301
|
+
if (threadId) {
|
|
1302
|
+
// Re-key onto the thread. channelType "11" = PUBLIC_THREAD, so the identity seam derives a
|
|
1303
|
+
// guild `channel` surface with its own surfaceKey (discord/<guild>/<threadId>).
|
|
1304
|
+
// parentChannelId is FREE here โ the thread hangs off the mention's own base channel
|
|
1305
|
+
// (msg.channelId), no REST needed โ and it keeps the session's DOMAIN/SPEND grain on
|
|
1306
|
+
// that channel (binding.domainSurface โ partition.domainKey) while the surfaceKey /
|
|
1307
|
+
// session / engagement stay on the thread (item 22).
|
|
1308
|
+
// NO `messageId` here, deliberately (OPE-671): this REKEYS `channelId` onto the newly-created
|
|
1309
|
+
// thread, but `msg.id` still lives in the BASE channel (the mention that spawned the thread) โ
|
|
1310
|
+
// pairing them would address a reaction at a message the thread channel doesn't own. The ๐
|
|
1311
|
+
// swap-tracked reaction has the identical constraint (see `original: {channelId: msg.channelId,
|
|
1312
|
+
// messageId: msg.id}` below, which stays on the BASE channel for the same reason). A turn
|
|
1313
|
+
// re-keyed onto a fresh auto-thread therefore gets no โ โ a narrower, PRE-EXISTING gap (today
|
|
1314
|
+
// NO Discord turn gets one), not a regression.
|
|
1315
|
+
const auth = buildDiscordAuth({
|
|
1316
|
+
userId: msg.author.id,
|
|
1317
|
+
guildId: msg.guildId,
|
|
1318
|
+
channelId: threadId,
|
|
1319
|
+
channelType: "11",
|
|
1320
|
+
parentChannelId: msg.channelId,
|
|
1321
|
+
});
|
|
1322
|
+
return { auth, continuationToken: `discord:${threadId}`, state: { channelId: threadId }, engagementKey: threadId };
|
|
1323
|
+
}
|
|
1324
|
+
// No thread โ base channel, identical to the pre-auto-thread behaviour.
|
|
1325
|
+
return {
|
|
1326
|
+
auth: baseAuth,
|
|
1327
|
+
continuationToken: `discord:${msg.channelId}`,
|
|
1328
|
+
state: { channelId: msg.channelId },
|
|
1329
|
+
engagementKey: msg.channelId,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// --- status affordances: completion plan (pure) -----------------------------
|
|
1334
|
+
// On completion there are two moving parts โ the ๐โโ
reaction swap on the ORIGINAL message
|
|
1335
|
+
// (auto-threaded mentions only โ the one case with reliably-fresh coords), and posting the
|
|
1336
|
+
// reply. Both hang off the SAME source-of-truth decision the surface already trusts:
|
|
1337
|
+
// finalMessageAction (slack-events). planCompletion is the pure translation from that decision
|
|
1338
|
+
// (+ whether this turn seeded reaction coords) into the concrete operations message.completed
|
|
1339
|
+
// must perform โ so the mapping is unit-tested without any live Discord call, and the handler
|
|
1340
|
+
// is a thin executor.
|
|
1341
|
+
|
|
1342
|
+
/** How to reconcile the ๐ reaction on completion (auto-threaded mentions only). */
|
|
1343
|
+
export type ReactionResolution =
|
|
1344
|
+
| "clear" // remove the ๐ โ the turn is DONE (answered or judged silent); absence = done,
|
|
1345
|
+
// the Slack-established convention (โ
is reserved for humans, never the bot)
|
|
1346
|
+
| "none"; // no tracked reaction this turn โ leave the message untouched
|
|
1347
|
+
|
|
1348
|
+
/** How to deliver the final reply. Always a FRESH post โ never an edit (no "(edited)" tag). */
|
|
1349
|
+
export type CompletionDelivery =
|
|
1350
|
+
| { readonly kind: "post"; readonly chunks: readonly string[] }
|
|
1351
|
+
| { readonly kind: "none" }; // judged silence / interim tool turn โ nothing to post
|
|
1352
|
+
|
|
1353
|
+
export interface CompletionPlan {
|
|
1354
|
+
readonly reaction: ReactionResolution;
|
|
1355
|
+
readonly delivery: CompletionDelivery;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* Translate the shared finalMessageAction decision into the concrete completion operations. Pure โ
|
|
1360
|
+
* no I/O, no env, fully unit-tested.
|
|
1361
|
+
* - post โ POST the reply fresh (split to Discord's 2000-char cap); swap ๐โโ
when this turn
|
|
1362
|
+
* placed a swap-tracked reaction (auto-threaded mention).
|
|
1363
|
+
* - silent โ post nothing; clear the ๐ if one was swap-tracked (don't leave "seen, working"
|
|
1364
|
+
* standing on a mention the agent decided not to answer).
|
|
1365
|
+
* - buffer-tool-status โ interim tool turn, not the final reply โ do nothing at all.
|
|
1366
|
+
* `reactionActive` = this turn seeded original-message coords (status UX on + auto-threaded).
|
|
1367
|
+
* The non-threaded ๐ (DMs, in-thread mentions) is a plain "seen" ack, deliberately NOT swapped
|
|
1368
|
+
* or cleared โ the answer lands right under the message, so the ack can simply stand.
|
|
1369
|
+
*/
|
|
1370
|
+
export function planCompletion(input: {
|
|
1371
|
+
readonly action: FinalMessageAction;
|
|
1372
|
+
readonly reactionActive: boolean;
|
|
1373
|
+
/** An approval card was posted this turn (approvals/card-signal.ts) โ the card IS the
|
|
1374
|
+
* reply, so the model's narration is suppressed (same rule as Slack: "the card should
|
|
1375
|
+
* suffice"). The turn still counts as answered โ the ๐ still clears. */
|
|
1376
|
+
readonly cardPostedThisTurn?: boolean;
|
|
1377
|
+
}): CompletionPlan {
|
|
1378
|
+
const { action, reactionActive, cardPostedThisTurn } = input;
|
|
1379
|
+
if (action.kind === "buffer-tool-status") {
|
|
1380
|
+
// Not the final message โ an interim tool-call turn. Touch nothing; the real reply comes later.
|
|
1381
|
+
return { reaction: "none", delivery: { kind: "none" } };
|
|
1382
|
+
}
|
|
1383
|
+
if (action.kind === "post") {
|
|
1384
|
+
return {
|
|
1385
|
+
reaction: reactionActive ? "clear" : "none",
|
|
1386
|
+
delivery: cardPostedThisTurn ? { kind: "none" } : { kind: "post", chunks: splitForDiscord(action.text) },
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
// action.kind === "silent" (empty final message OR the NO_REPLY sentinel โ judged not warranted).
|
|
1390
|
+
return {
|
|
1391
|
+
reaction: reactionActive ? "clear" : "none",
|
|
1392
|
+
delivery: { kind: "none" },
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Assemble the session state SEEDED into `send` for a turn. This seed only takes effect when
|
|
1398
|
+
* the send STARTS a session (eve drops it on the resume path โ see the session-state comment
|
|
1399
|
+
* up top), which is exactly why `original` is only ever passed on the auto-thread turn: a fresh
|
|
1400
|
+
* thread id guarantees a fresh session, so the seeded reaction coords are guaranteed to belong
|
|
1401
|
+
* to THIS turn's triggering message. The reply surface (`channelId`) follows the re-key (the
|
|
1402
|
+
* thread when threaded); the reaction target stays pinned to the mention in the BASE channel so
|
|
1403
|
+
* ๐/โ
land where the human is looking. No original coords โ completion does no reaction swap.
|
|
1404
|
+
*/
|
|
1405
|
+
export function buildGatewayState(input: {
|
|
1406
|
+
readonly replyChannelId: string;
|
|
1407
|
+
/** The guild the reply surface is in โ seeded so the completion handler can rebuild the
|
|
1408
|
+
* engagement partition (see DiscordGatewayState.guildId). Absent for DMs (no guild). */
|
|
1409
|
+
readonly guildId?: string;
|
|
1410
|
+
readonly original?: { readonly channelId: string; readonly messageId: string };
|
|
1411
|
+
}): DiscordGatewayState {
|
|
1412
|
+
return {
|
|
1413
|
+
channelId: input.replyChannelId,
|
|
1414
|
+
guildId: input.guildId,
|
|
1415
|
+
originalChannelId: input.original?.channelId,
|
|
1416
|
+
originalMessageId: input.original?.messageId,
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Consume this turn's reaction-swap coordinates from the LIVE session state: return the
|
|
1422
|
+
* original-message coords and CLEAR them (consume-once). The clearing is the correctness
|
|
1423
|
+
* guarantee on resumed sessions โ a later turn can never react on a PREVIOUS turn's message;
|
|
1424
|
+
* the coords only ever exist on the session-creating auto-thread turn. Mutates the passed
|
|
1425
|
+
* state in place (eve write-back persists it). Pure in/out mapping โ unit-tested.
|
|
1426
|
+
*/
|
|
1427
|
+
export function takeTurnStatus(state: DiscordGatewayState): {
|
|
1428
|
+
original?: { channelId: string; messageId: string };
|
|
1429
|
+
} {
|
|
1430
|
+
const original =
|
|
1431
|
+
state.originalChannelId && state.originalMessageId
|
|
1432
|
+
? { channelId: state.originalChannelId, messageId: state.originalMessageId }
|
|
1433
|
+
: undefined;
|
|
1434
|
+
state.originalChannelId = undefined;
|
|
1435
|
+
state.originalMessageId = undefined;
|
|
1436
|
+
return { original };
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// --- single-window: the cross-surface shared-context window (Discord's adoption) --------------
|
|
1440
|
+
// The GitHub reference implementation ported to Discord (github.ts shouldSuppressNativeMention /
|
|
1441
|
+
// ambientDelivery โ channels/unified-window.ts is the shared seam, channels/unified.ts the
|
|
1442
|
+
// delivery spine). On a domain that opted into crossSurfaceWindow, every wake this gateway would
|
|
1443
|
+
// dispatch to its per-channel session is DIVERTED through eve's cross-channel `receive` into the
|
|
1444
|
+
// synthetic `unified` channel instead, so a Discord message lands in the SAME literal context
|
|
1445
|
+
// window as the domain's Slack/GitHub/Linear/Notion wakes. An @mention rides as an ADDRESSED
|
|
1446
|
+
// `direct-mention` wake (owed a reply โ delivered back into the exact Discord channel by
|
|
1447
|
+
// unified-reply.ts's discord arm via the durable wakeIdโorigin map); engaged and name-drop wakes
|
|
1448
|
+
// ride as judged watch wakes (terminal prose hard-dropped, reply only via the reply tool). Like
|
|
1449
|
+
// Notion, Discord has ONE dispatch path โ the divert IS the native suppression: when the wake
|
|
1450
|
+
// diverts, none of this file's native affordances (auto-thread, ๐, typing, completion post-back)
|
|
1451
|
+
// run, exactly as GitHub's supervisor turns suppress eyes/checkout.
|
|
1452
|
+
//
|
|
1453
|
+
// (OPE-356 added a SECOND, narrower divert on this same path โ a DM into its OWN window, which is
|
|
1454
|
+
// the opposite of a shared one. It keeps the ๐ and drops the rest; the one-line exception, and why
|
|
1455
|
+
// typing specifically must NOT survive it, is stated at its own call site in dispatchTurn.)
|
|
1456
|
+
// Flag-off (or the privacy
|
|
1457
|
+
// floor not passed) is byte-identical: the divert block is never entered.
|
|
1458
|
+
|
|
1459
|
+
/** The cross-channel dispatch primitive the route hands the dispatch (eve's public
|
|
1460
|
+
* `args.to` โ the same primitive github.ts's fan-out + divert legs use; it was `args.receive`
|
|
1461
|
+
* before eve 0.31 split the call into `to(channel, target).send(message, options)`). */
|
|
1462
|
+
export type UnifiedReceive = RouteHandlerArgs["to"];
|
|
1463
|
+
|
|
1464
|
+
// UNIFIED grain for a Discord surface's DOMAIN-grain key (a thread asks its parent channel's
|
|
1465
|
+
// domain โ domainKeyOf, the same grain the nameDropWake and spend gates use). FAIL-SOFT to the
|
|
1466
|
+
// per-container grain (the byte-identical path): a config-load error must degrade to today's
|
|
1467
|
+
// behaviour, never take the channel down or mis-key a session. Mirrors github.ts githubGrainFor.
|
|
1468
|
+
function discordGrainFor(domainKey: string): SupervisorGrain {
|
|
1469
|
+
try {
|
|
1470
|
+
return supervisorGrainFor(domainKey, getConfig().loadDomains());
|
|
1471
|
+
} catch {
|
|
1472
|
+
return { unified: false, registryKey: domainKey };
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// The ambient-calibration dial for a diverted watch wake (memory/domain.ts engagementTierFor,
|
|
1477
|
+
// resolved at the DOMAIN grain). Fail-soft: neutral. Mirrors github.ts githubEngagementTier.
|
|
1478
|
+
function discordEngagementTier(domainKey: string): WakeEngagementTier | undefined {
|
|
1479
|
+
try {
|
|
1480
|
+
return engagementTierFor(domainKey, getConfig().loadDomains());
|
|
1481
|
+
} catch {
|
|
1482
|
+
return undefined;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* THE DISCORD PRIVACY FLOOR (fail-closed): may this surface join a PRIVATE cross-surface window?
|
|
1488
|
+
*
|
|
1489
|
+
* Every single-window surface needs a POSITIVE privacy fact before its wakes may share a context
|
|
1490
|
+
* window with the domain's private Slack/GitHub/Linear/Notion conversations (GitHub:
|
|
1491
|
+
* `repository.private === true`; Slack: the channel positively confirmed internal; Notion: the
|
|
1492
|
+
* operator-vouch register). Discord's honest answer is that NO reliable positive signal exists
|
|
1493
|
+
* in what reaches this gateway:
|
|
1494
|
+
* - The guild is the tenant boundary (external people aren't guild members), but guilds are
|
|
1495
|
+
* routinely PUBLIC/community servers with open invites โ guild membership alone proves
|
|
1496
|
+
* nothing about who can read or post.
|
|
1497
|
+
* - The relay's MESSAGE_CREATE payload carries NO channel-permission data. The identity
|
|
1498
|
+
* layer's `everyoneCanView` field (discord-identity.ts) is documented "absent today" โ and
|
|
1499
|
+
* its `discordVisibility` default of `private` is the WRONG direction here: that default is
|
|
1500
|
+
* conservative for MEMORY (learnings stay surface-tier), but for window ADMISSION
|
|
1501
|
+
* "default-private" would be permissive โ it would admit every guild channel, including
|
|
1502
|
+
* @everyone-viewable channels in public community guilds. A default is not a positive fact.
|
|
1503
|
+
* So, exactly like Notion (whose public_url was similarly misleading), the floor is the
|
|
1504
|
+
* OPERATOR-VOUCH register (identity/trust.ts isVouchedInternal โ the deployer's out-of-band
|
|
1505
|
+
* "this guild/channel is members-only internal" assertion, keyed on the workspaceKey
|
|
1506
|
+
* `discord/<guild>` or a surfaceKey `discord/<guild>/<channel>`; a thread also honours a vouch
|
|
1507
|
+
* on its PARENT channel via the domain-grain key), or the OE_TRUST=internal blanket switch for
|
|
1508
|
+
* internal-only deploys. Code enforces, the operator vouches โ never an inference from a payload
|
|
1509
|
+
* field. Fail-CLOSED: un-vouched โ false โ the wake stays on the native per-channel path.
|
|
1510
|
+
*
|
|
1511
|
+
* DMs / group-DMs (no guildId) NEVER pass โ unconditionally, even under OE_TRUST=internal
|
|
1512
|
+
* (mirroring how the e2e driver allowlist excludes guildless surfaces): a DM is a PERSONAL
|
|
1513
|
+
* surface with no tenant boundary at all, and its content must never enter a team-shared window.
|
|
1514
|
+
*
|
|
1515
|
+
* ATTACKER ANALYSIS (what goes wrong if this rule is too loose): if a public/@everyone-readable
|
|
1516
|
+
* channel were admitted, anyone who can join the guild (open invite) could (a) READ private
|
|
1517
|
+
* cross-surface context โ an @mention here would recall, into a public channel, whatever the
|
|
1518
|
+
* shared window absorbed from the domain's private repos/channels/docs โ and (b) WRITE into the
|
|
1519
|
+
* shared context: their message text enters the one window every surface's supervisor judges
|
|
1520
|
+
* from, a persistent cross-surface prompt-injection foothold. The vouch register makes admission
|
|
1521
|
+
* an explicit operator assertion with exactly that blast radius in view.
|
|
1522
|
+
*
|
|
1523
|
+
* Pure (vouch + env injectable) โ the truth table is unit-tested without a live register
|
|
1524
|
+
* (scripts/unified-window-test.ts). Mirrors notion.ts's floor + github.ts's positive-privacy gate.
|
|
1525
|
+
*/
|
|
1526
|
+
export function discordSingleWindowPrivacyOk(
|
|
1527
|
+
input: {
|
|
1528
|
+
readonly guildId: string | undefined;
|
|
1529
|
+
/** `discord/<guild>` โ a workspace-level vouch covers every channel in the guild. */
|
|
1530
|
+
readonly workspaceKey: string;
|
|
1531
|
+
/** `discord/<guild>/<channel-or-thread>` โ a surface-level vouch narrows to one. */
|
|
1532
|
+
readonly surfaceKey: string;
|
|
1533
|
+
/** The DOMAIN-grain key (a thread's parent channel โ domainKeyOf); a vouch on the parent
|
|
1534
|
+
* channel covers its threads. Equal to surfaceKey for a non-thread. */
|
|
1535
|
+
readonly domainKey?: string;
|
|
1536
|
+
},
|
|
1537
|
+
deps: { readonly vouched?: (workspaceKey: string, surfaceKey: string) => boolean; readonly trustEnv?: string | undefined } = {},
|
|
1538
|
+
): boolean {
|
|
1539
|
+
if (!input.guildId) return false; // DM/group-DM: NEVER, unconditionally (no tenant boundary)
|
|
1540
|
+
const vouched = deps.vouched ?? isVouchedInternal;
|
|
1541
|
+
const trustEnv = "trustEnv" in deps ? deps.trustEnv : process.env.OE_TRUST;
|
|
1542
|
+
if (trustEnv === "internal") return true; // the blanket internal-only-deploy switch
|
|
1543
|
+
if (vouched(input.workspaceKey, input.surfaceKey)) return true;
|
|
1544
|
+
// A thread inherits its parent channel's vouch (the domain-grain key โ same widening the
|
|
1545
|
+
// nameDropWake/spend gates apply; a vouch names channels, threads come and go under them).
|
|
1546
|
+
return input.domainKey !== undefined && input.domainKey !== input.surfaceKey && vouched(input.workspaceKey, input.domainKey);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* The Discord arm of the ONE single-window mention decision โ a thin shim over the shared seam
|
|
1551
|
+
* (unified-window.ts {@link singleWindowMentionPolicy}), exactly like github.ts's
|
|
1552
|
+
* shouldSuppressNativeMention and slack-events.ts's slackSingleWindowMentionPolicy:
|
|
1553
|
+
* `suppressNative` โบ the divert owns the @mention (this file's native dispatch โ auto-thread,
|
|
1554
|
+
* ๐, typing, per-channel session โ stands down); `addressing` is the `direct-mention` stamp the
|
|
1555
|
+
* diverted wake carries so the unified supervisor knows it is owed an answer โ which it must give
|
|
1556
|
+
* through the `reply` tool, since OPE-1982 deleted the prose route. Both derive from the SAME predicate (`singleWindow && privacyOk && isMention`), so
|
|
1557
|
+
* suppress-and-keep stay perfectly complementary: never a double reply, never a gap. `privacyOk`
|
|
1558
|
+
* is {@link discordSingleWindowPrivacyOk}'s verdict (fail-closed). Pure โ unit-tested truth table.
|
|
1559
|
+
*/
|
|
1560
|
+
export function discordSingleWindowMentionPolicy(singleWindow: boolean, isMention: boolean, privacyOk: boolean): SingleWindowMentionPolicy {
|
|
1561
|
+
return singleWindowMentionPolicy(singleWindow, isMention, privacyOk);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/** Everything {@link buildDiscordUnifiedWake} needs โ extracted by the divert wiring, built
|
|
1565
|
+
* directly by tests. */
|
|
1566
|
+
export interface DiscordUnifiedWakeInput {
|
|
1567
|
+
readonly guildId: string;
|
|
1568
|
+
/** The conversation the trigger arrived in (a thread's OWN channel id when in a thread). */
|
|
1569
|
+
readonly channelId: string;
|
|
1570
|
+
/** The thread's parent channel, when known (auth enrichment) โ the locus CONTAINER. */
|
|
1571
|
+
readonly parentChannelId?: string;
|
|
1572
|
+
readonly isThread: boolean;
|
|
1573
|
+
readonly addressing: "direct-mention" | "channel-watch" | "name-drop";
|
|
1574
|
+
/** This conversation carries an ENGAGED registry row โ an @mention (or an earned reply) invited
|
|
1575
|
+
* the agent and the invitation still stands โ so a JUDGED wake states `follow-up="thread-engaged"`
|
|
1576
|
+
* (channels/engaged-wake.ts). Unset/false โ no attribute, byte-identical. */
|
|
1577
|
+
readonly threadEngaged?: boolean;
|
|
1578
|
+
readonly wakeId: string;
|
|
1579
|
+
readonly engagementTier?: WakeEngagementTier;
|
|
1580
|
+
/** The OUTGOING generation's recorded TAIL (OPE-794), rendered as this first wake's backscroll.
|
|
1581
|
+
* Only a FRESH generation's first wake carries one. */
|
|
1582
|
+
readonly tail?: readonly WakeMessageInput[];
|
|
1583
|
+
/** The hydrated surrounding conversation, STRUCTURED โ `renderWake` renders it through core's own
|
|
1584
|
+
* road, which de-duplicates it against {@link tail} and caps BELOW that (OPE-1749). This road used
|
|
1585
|
+
* to hand a pre-rendered string down beside the wake, which bypassed both. */
|
|
1586
|
+
readonly contextLines?: readonly ContextLine[];
|
|
1587
|
+
/** `renderContext`'s provenance label for {@link contextLines}. */
|
|
1588
|
+
readonly contextSource?: string;
|
|
1589
|
+
/** The bot's display name โ name-drop wakes only (what the human actually said). */
|
|
1590
|
+
readonly botName?: string;
|
|
1591
|
+
/** This surface could not read its own history since the seat's last wake (core
|
|
1592
|
+
* channels/seat-catch-up.ts). Rendered as a trusted `<system-note>`; unset โ no note. */
|
|
1593
|
+
readonly catchUpUnavailable?: string;
|
|
1594
|
+
readonly trigger: {
|
|
1595
|
+
readonly id: string;
|
|
1596
|
+
readonly author: string;
|
|
1597
|
+
readonly authorId: string;
|
|
1598
|
+
readonly sentAt: string;
|
|
1599
|
+
readonly text: string;
|
|
1600
|
+
/**
|
|
1601
|
+
* Resolved per-wake trust tier (identity/wake-trust.ts).
|
|
1602
|
+
*
|
|
1603
|
+
* ๐ด REQUIRED, NOT OPTIONAL (OPE-596) โ see LinearSupervisorWakeInput.trigger.trust. The field
|
|
1604
|
+
* used to default UPWARD (`?? (direct ? "principal" : "peer")`), which is fail-OPEN, and since
|
|
1605
|
+
* OPE-577 the tier decides the `<untrusted_external_data>` fence. A caller that cannot resolve
|
|
1606
|
+
* a tier states the fail-closed one itself: `relay` for a machine author, `unauth` for an
|
|
1607
|
+
* unvouchable human.
|
|
1608
|
+
*/
|
|
1609
|
+
readonly trust: WakeTrust;
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* Build the NormalizedWake for one DIVERTED Discord wake โ the exact shape
|
|
1615
|
+
* buildGitHubSupervisorWake produces for GitHub, with Discord's locus mapping: container =
|
|
1616
|
+
* the (parent) channel, conversation = the thread when the trigger was in one. Always stamps
|
|
1617
|
+
* the platform address + P5 disclosure + wake-id (this builder exists ONLY for the
|
|
1618
|
+
* single-window path โ the native dispatch renders via renderTurn/renderNameDropTurn,
|
|
1619
|
+
* untouched). renderWake then emits the `{message, context}` every OE dispatch sends โ the
|
|
1620
|
+
* SAME renderer Slack/GitHub/Linear/Notion use, with NO platform branch. Pure โ unit-testable.
|
|
1621
|
+
*/
|
|
1622
|
+
export function buildDiscordUnifiedWake(input: DiscordUnifiedWakeInput): NormalizedWake {
|
|
1623
|
+
const direct = input.addressing === "direct-mention";
|
|
1624
|
+
return {
|
|
1625
|
+
platform: "discord",
|
|
1626
|
+
// An addressed @mention is reason "mention" (honest, rides the envelope); engaged/name-drop
|
|
1627
|
+
// chatter is "channel-activity". Every addressing here keeps the full envelope, so the
|
|
1628
|
+
// wake-id survives for the reply tool (the ONLY reply route on the unified channel).
|
|
1629
|
+
reason: direct ? "mention" : "channel-activity",
|
|
1630
|
+
addressing: input.addressing,
|
|
1631
|
+
// The ambient calibration dial applies only to the watch wake (an addressed mention is owed
|
|
1632
|
+
// an answer; a name-drop has its own higher-bar preamble) โ mirrors the GitHub builder.
|
|
1633
|
+
engagementTier: input.addressing === "channel-watch" ? input.engagementTier : undefined,
|
|
1634
|
+
// The ENROLMENT fact, through the ONE shared helper every enveloped surface calls
|
|
1635
|
+
// (channels/engaged-wake.ts, OPE-1740). `nameDrop` below still wins in renderWake, so a
|
|
1636
|
+
// name-drop wake's own mechanic is unchanged.
|
|
1637
|
+
followUp: engagedFollowUpContract({ addressing: input.addressing, threadEngaged: input.threadEngaged }),
|
|
1638
|
+
wakeId: input.wakeId,
|
|
1639
|
+
locus: {
|
|
1640
|
+
// Container = the channel (a thread's PARENT when known โ the grain domains/spend use).
|
|
1641
|
+
channel: { id: input.parentChannelId ?? input.channelId },
|
|
1642
|
+
// Conversation = the thread (its own channel id) when the trigger was in one.
|
|
1643
|
+
...(input.isThread ? { thread: { ts: input.channelId } } : {}),
|
|
1644
|
+
},
|
|
1645
|
+
// The `botName` guard is a FIDELITY guard, not a data dependency (OPE-770 removed the field the
|
|
1646
|
+
// block used to carry): a relay that reported no bot name never matched one, so it gets no
|
|
1647
|
+
// `follow-up` attribute โ exactly the bytes it rendered before.
|
|
1648
|
+
...(input.addressing === "name-drop" && input.botName
|
|
1649
|
+
? {
|
|
1650
|
+
nameDrop: {
|
|
1651
|
+
// The honest follow-up contract, mirroring the native path's engagement mechanics
|
|
1652
|
+
// (dispatchTurn): a THREAD name-drop engaged the thread at dispatch; a base-channel
|
|
1653
|
+
// name-drop deliberately does not follow (the engaged-channel surprise).
|
|
1654
|
+
followUp: input.isThread ? ("thread-engaged" as const) : ("channel-not-following" as const),
|
|
1655
|
+
},
|
|
1656
|
+
}
|
|
1657
|
+
: {}),
|
|
1658
|
+
backscroll: input.tail && input.tail.length > 0 ? input.tail : undefined,
|
|
1659
|
+
// The hydration's own slot (OPE-1749): `renderWake` de-duplicates it against `backscroll` above
|
|
1660
|
+
// and caps below that, so this road renders through the same core ordering as every other surface.
|
|
1661
|
+
...(input.contextLines && input.contextLines.length > 0
|
|
1662
|
+
? { contextLines: input.contextLines, ...(input.contextSource === undefined ? {} : { contextSource: input.contextSource }) }
|
|
1663
|
+
: {}),
|
|
1664
|
+
...(input.catchUpUnavailable !== undefined ? { catchUpUnavailable: input.catchUpUnavailable } : {}),
|
|
1665
|
+
trigger: mkMessageTrigger({
|
|
1666
|
+
from: "human", // bot authors were dropped at ingress (authForRelayMessage)
|
|
1667
|
+
author: input.trigger.author,
|
|
1668
|
+
authorId: input.trigger.authorId,
|
|
1669
|
+
id: input.trigger.id,
|
|
1670
|
+
sentAt: input.trigger.sentAt,
|
|
1671
|
+
// The caller's stated per-person tier โ no default (OPE-596).
|
|
1672
|
+
trust: input.trigger.trust,
|
|
1673
|
+
// The @mention response-obligation flag (rendered `mention="true"`).
|
|
1674
|
+
mention: direct ? true : undefined,
|
|
1675
|
+
body: input.trigger.text,
|
|
1676
|
+
}),
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/** Everything {@link discordNativeWake} needs โ extracted by the native dispatch, built directly
|
|
1681
|
+
* by tests. The NON-unified sibling of {@link DiscordUnifiedWakeInput}: no wake-id (per-channel
|
|
1682
|
+
* session; the reply posts back via message.completed, not a routed reply tool) and no P5
|
|
1683
|
+
* disclosure (single-surface, not a cross-surface window). */
|
|
1684
|
+
export interface DiscordNativeWakeInput {
|
|
1685
|
+
/** The conversation the trigger arrived in (a thread's OWN channel id when in a thread). */
|
|
1686
|
+
readonly channelId: string;
|
|
1687
|
+
/** The thread's parent channel, when known โ the locus CONTAINER (domain/spend grain). */
|
|
1688
|
+
readonly parentChannelId?: string;
|
|
1689
|
+
readonly isThread: boolean;
|
|
1690
|
+
/** A DM (channel type 1/3) โ an inherently-addressed `direct` wake whose reason is `dm`. */
|
|
1691
|
+
readonly isDm: boolean;
|
|
1692
|
+
readonly addressing: "direct" | "thread-follow" | "name-drop";
|
|
1693
|
+
/** This conversation carries a PARTICIPATING, un-stopped engagement row โ see
|
|
1694
|
+
* {@link DiscordUnifiedWakeInput.threadEngaged}. ๐ด NOT derivable from `thread-follow`: that tier
|
|
1695
|
+
* is reached past an `isEngaged` gate a name-drop's WARM latch (`participating: false`) also
|
|
1696
|
+
* satisfies, and a seat nobody invited must not be told it is enrolled. */
|
|
1697
|
+
readonly threadEngaged?: boolean;
|
|
1698
|
+
/** The bot's display name โ name-drop wakes only (what the human actually said). */
|
|
1699
|
+
readonly botName?: string;
|
|
1700
|
+
/** RAW side-channel backscroll (mention/name-drop hydration) โ renderWake wraps it (W3). */
|
|
1701
|
+
readonly contextLines?: readonly ContextLine[];
|
|
1702
|
+
readonly contextSource?: string;
|
|
1703
|
+
readonly trigger: {
|
|
1704
|
+
readonly id: string;
|
|
1705
|
+
readonly author: string;
|
|
1706
|
+
readonly authorId: string;
|
|
1707
|
+
readonly sentAt: string;
|
|
1708
|
+
readonly text: string;
|
|
1709
|
+
/** Resolved per-wake trust tier (identity/wake-trust.ts). ๐ด REQUIRED (OPE-596) โ see
|
|
1710
|
+
* {@link DiscordUnifiedWakeInput}'s trigger tier for the whole reason. */
|
|
1711
|
+
readonly trust: WakeTrust;
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/**
|
|
1716
|
+
* Build the NormalizedWake for one NATIVE (non-diverted) Discord wake โ the per-channel-session
|
|
1717
|
+
* path that renderTurn/renderNameDropTurn used to hand-render. It maps into the SAME shared type
|
|
1718
|
+
* every other surface uses, so renderWake emits the one canonical dialect with NO platform branch:
|
|
1719
|
+
* - `direct` (DM / @mention): the conversation driver โ renderWake returns the body VERBATIM (no
|
|
1720
|
+
* preamble, no envelope, unwrapped โ W4), byte-identical to the old renderTurn(true).
|
|
1721
|
+
* - `thread-follow` (engaged non-mention) / `name-drop`: JUDGED โ the canonical Slack-parity
|
|
1722
|
+
* preamble + the full wake envelope (the migration's deliberate convergence; the old Discord
|
|
1723
|
+
* prose stub had DRIFTED away from the social-chatter / never-re-answer / PREFER_NO_REPLY_TOOL
|
|
1724
|
+
* sentences, each of which encodes an observed judge failure).
|
|
1725
|
+
* Locus: container = the (parent) channel; conversation = the thread when the trigger was in one.
|
|
1726
|
+
* No wakeId is minted (a native wake answers through the base `reply`, which names no wake). Pure โ
|
|
1727
|
+
* unit-testable without a live Discord call.
|
|
1728
|
+
*/
|
|
1729
|
+
export function discordNativeWake(input: DiscordNativeWakeInput): NormalizedWake {
|
|
1730
|
+
const direct = input.addressing === "direct";
|
|
1731
|
+
return {
|
|
1732
|
+
platform: "discord",
|
|
1733
|
+
// Direct is DM or @mention (reason is cosmetic โ the direct render carries no envelope); a
|
|
1734
|
+
// judged wake's reason honestly reflects thread vs base-channel activity.
|
|
1735
|
+
reason: direct ? (input.isDm ? "dm" : "mention") : input.isThread ? "thread-activity" : "channel-activity",
|
|
1736
|
+
addressing: input.addressing,
|
|
1737
|
+
// The ENROLMENT fact, through the ONE shared helper (channels/engaged-wake.ts, OPE-1740), off the
|
|
1738
|
+
// engagement row the dispatch already read โ never off the `thread-follow` tier, which a warm
|
|
1739
|
+
// name-drop latch also reaches. `direct` renders no envelope and `nameDrop` wins in renderWake,
|
|
1740
|
+
// so both are unchanged.
|
|
1741
|
+
followUp: engagedFollowUpContract({ addressing: input.addressing, threadEngaged: input.threadEngaged }),
|
|
1742
|
+
locus: {
|
|
1743
|
+
// Container = the channel (a thread's PARENT when known โ the grain domains/spend use).
|
|
1744
|
+
channel: { id: input.parentChannelId ?? input.channelId },
|
|
1745
|
+
// Conversation = the thread (its own channel id) when the trigger was in one.
|
|
1746
|
+
...(input.isThread ? { thread: { ts: input.channelId } } : {}),
|
|
1747
|
+
},
|
|
1748
|
+
// The `botName` guard is a FIDELITY guard, not a data dependency (OPE-770 removed the field the
|
|
1749
|
+
// block used to carry): a relay that reported no bot name never matched one, so it gets no
|
|
1750
|
+
// `follow-up` attribute โ exactly the bytes it rendered before.
|
|
1751
|
+
...(input.addressing === "name-drop" && input.botName
|
|
1752
|
+
? {
|
|
1753
|
+
nameDrop: {
|
|
1754
|
+
// The honest follow-up contract, matching the native engagement mechanics dispatchTurn
|
|
1755
|
+
// applied: a THREAD name-drop engaged the thread at dispatch (follow-ups flow); a
|
|
1756
|
+
// base-channel name-drop deliberately does NOT follow (the engaged-channel surprise).
|
|
1757
|
+
followUp: input.isThread ? ("thread-engaged" as const) : ("channel-not-following" as const),
|
|
1758
|
+
},
|
|
1759
|
+
}
|
|
1760
|
+
: {}),
|
|
1761
|
+
...(input.contextLines ? { contextLines: input.contextLines, contextSource: input.contextSource } : {}),
|
|
1762
|
+
trigger: mkMessageTrigger({
|
|
1763
|
+
from: "human", // bot authors were dropped at ingress (authForRelayMessage / the wake gate)
|
|
1764
|
+
author: input.trigger.author,
|
|
1765
|
+
authorId: input.trigger.authorId,
|
|
1766
|
+
id: input.trigger.id,
|
|
1767
|
+
sentAt: input.trigger.sentAt,
|
|
1768
|
+
// The caller's stated per-person tier โ no default (OPE-596).
|
|
1769
|
+
trust: input.trigger.trust,
|
|
1770
|
+
// The @mention response-obligation flag renders only on the enveloped judged paths; a raw
|
|
1771
|
+
// `direct` body carries no envelope so the flag never surfaces (left unset there).
|
|
1772
|
+
mention: undefined,
|
|
1773
|
+
body: input.trigger.text,
|
|
1774
|
+
}),
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/** Everything {@link buildDiscordDmWake} needs โ extracted by the DM divert, built directly by
|
|
1779
|
+
* tests. Deliberately NARROWER than {@link DiscordUnifiedWakeInput}: a DM has no guild, no thread,
|
|
1780
|
+
* no engagement tier and no name-drop, and every one of those absences is a property of the
|
|
1781
|
+
* surface rather than a field a caller might or might not fill in. */
|
|
1782
|
+
export interface DiscordDmWakeInput {
|
|
1783
|
+
/** The DM (or group-DM) channel id โ a DM's whole locus. */
|
|
1784
|
+
readonly channelId: string;
|
|
1785
|
+
readonly wakeId: string;
|
|
1786
|
+
/** The OUTGOING generation's recorded TAIL (OPE-794), rendered as this first wake's backscroll.
|
|
1787
|
+
* Only a FRESH generation's first wake carries one. */
|
|
1788
|
+
readonly tail?: readonly WakeMessageInput[];
|
|
1789
|
+
readonly trigger: {
|
|
1790
|
+
readonly id: string;
|
|
1791
|
+
readonly author: string;
|
|
1792
|
+
readonly authorId: string;
|
|
1793
|
+
readonly sentAt: string;
|
|
1794
|
+
readonly text: string;
|
|
1795
|
+
/** Resolved per-wake trust tier (identity/wake-trust.ts). ๐ด REQUIRED (OPE-596) โ the field
|
|
1796
|
+
* used to default to `principal`, the highest tier, on the reasoning that a DM partner IS the
|
|
1797
|
+
* seat's principal. That reasoning is about the ROOM, and the tier is about the AUTHOR. See
|
|
1798
|
+
* {@link DiscordUnifiedWakeInput}'s trigger tier. */
|
|
1799
|
+
readonly trust: WakeTrust;
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* THE 1:1 (and group) DM WAKE for a first-classed Discord DM window (OPE-356) โ the Discord twin of
|
|
1805
|
+
* slack-events.ts's `slackDmWake`, and it renders through the SAME core renderer with no platform
|
|
1806
|
+
* branch, so the two surfaces' DM envelopes are one dialect.
|
|
1807
|
+
*
|
|
1808
|
+
* ADDRESSING IS `direct-mention`, ALWAYS, doing the same two jobs it does on Slack. Semantically it
|
|
1809
|
+
* is simply TRUE: everything said in a DM is said to the agent, which is why this path bypasses the
|
|
1810
|
+
* engagement gate, the name-drop judge and the channel-watch silence bias entirely โ a DM IS ALWAYS
|
|
1811
|
+
* ANSWERED. Mechanically it is LOAD-BEARING: `direct-mention` is what stamps `oe_addressing` through
|
|
1812
|
+
* the divert, which marks the wake as OWED a reply and selects the `[direct]` preamble's reply
|
|
1813
|
+
* mandate. ๐ด IT IS NOT A DELIVERY PATH (OPE-1982): the addressed-prose bridge it used to arm is
|
|
1814
|
+
* deleted, so a DM answer written as prose rather than a `reply` call is DROPPED on that channel
|
|
1815
|
+
* like any other, and re-driven once towards the tool.
|
|
1816
|
+
*
|
|
1817
|
+
* `reason` is `dm`, not `mention`: the envelope's reason describes the SURFACE SHAPE. It is also the
|
|
1818
|
+
* one reason that survives the OPE-744 collapse of `mention` into `channel-activity`, because no
|
|
1819
|
+
* other attribute states "this is a direct-message room".
|
|
1820
|
+
*
|
|
1821
|
+
* NO `engagementTier`: that dial raises the bar for AMBIENT watching, and a DM is always answered.
|
|
1822
|
+
*
|
|
1823
|
+
* The `wakeId` is present because the unified channel is the delivery route: it is the id the model
|
|
1824
|
+
* must echo to `reply`, and the only way a DM answer gets out. Pure โ unit-testable.
|
|
1825
|
+
*/
|
|
1826
|
+
export function buildDiscordDmWake(input: DiscordDmWakeInput): NormalizedWake {
|
|
1827
|
+
return {
|
|
1828
|
+
platform: "discord",
|
|
1829
|
+
reason: "dm",
|
|
1830
|
+
addressing: "direct-mention",
|
|
1831
|
+
wakeId: input.wakeId,
|
|
1832
|
+
// A DM's container IS the DM channel: no parent, no thread, and โ unlike the cross-surface
|
|
1833
|
+
// builder โ no `platform` address stamp, because this window sees exactly one surface.
|
|
1834
|
+
locus: { channel: { id: input.channelId } },
|
|
1835
|
+
backscroll: input.tail && input.tail.length > 0 ? input.tail : undefined,
|
|
1836
|
+
trigger: mkMessageTrigger({
|
|
1837
|
+
from: "human", // bot authors are dropped at ingress (authForRelayMessage)
|
|
1838
|
+
author: input.trigger.author,
|
|
1839
|
+
authorId: input.trigger.authorId,
|
|
1840
|
+
id: input.trigger.id,
|
|
1841
|
+
sentAt: input.trigger.sentAt,
|
|
1842
|
+
// The caller's stated per-person tier โ no default (OPE-596).
|
|
1843
|
+
trust: input.trigger.trust,
|
|
1844
|
+
// The response-obligation flag, exactly as the Slack DM wake carries it.
|
|
1845
|
+
mention: true,
|
|
1846
|
+
body: input.trigger.text,
|
|
1847
|
+
}),
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
/**
|
|
1852
|
+
* THE DM WINDOW DIVERT (OPE-356) โ a Discord DM lands in its OWN first-class standing window
|
|
1853
|
+
* (channels/dm-seat.ts), the Discord parity of slack-front.ts's `scheduleDmWake`.
|
|
1854
|
+
*
|
|
1855
|
+
* WHAT IT FIXES. Until now a Discord DM ran on eve's per-channel continuation token: a session with
|
|
1856
|
+
* no window row, and therefore NO DISPATCHABLE ADDRESS. A DM that delegated could not be told the
|
|
1857
|
+
* answer had arrived โ `resolveCallerSeat` took the OPE-306 ANCHOR arm, both completion-wake sites
|
|
1858
|
+
* skipped the wake fail-closed, and the workaround was the worker posting its own report into the
|
|
1859
|
+
* DM. A window token is an address, so the DM seat now gets the whole standing-seat apparatus for
|
|
1860
|
+
* free: woken, redriven, rotated (on the DM thresholds โ `OE_DM_SEAT_ROTATE_*`, never the
|
|
1861
|
+
* supervisor's), spend-counted and audited through the seams every other seat already used. The
|
|
1862
|
+
* worker's direct report-back drops with it, deliberately: it compensated for a seat that could not
|
|
1863
|
+
* be woken, and keeping it would put two voices in one DM per delegated turn.
|
|
1864
|
+
*
|
|
1865
|
+
* ๐ด IT BYPASSES THE ENGAGEMENT GATE AND THE NAME-DROP JUDGE โ see {@link buildDiscordDmWake}. The
|
|
1866
|
+
* SPEND gate is NOT bypassed: `dispatchTurn` has already run `ingressSpendGate` (door `mention`)
|
|
1867
|
+
* before reaching here, and `divertToUnifiedWindow` runs its own attended-arrival gate on top.
|
|
1868
|
+
*
|
|
1869
|
+
* THE ENVELOPE CARRIES NO BACKSCROLL, on purpose and unchanged from the native path (`!isDm && โฆ`
|
|
1870
|
+
* in dispatchTurn): the 1:1 history IS the conversation and the seat's own long-lived session
|
|
1871
|
+
* already holds it. The one thing a FRESH generation carries is the recorded tail of the
|
|
1872
|
+
* generation it replaced (OPE-794).
|
|
1873
|
+
*
|
|
1874
|
+
* FAIL-CLOSED: a seat-resolution or dispatch failure PROPAGATES to the route's `waitUntil` catch.
|
|
1875
|
+
* There is deliberately no fall-back to the native send โ that would answer the human from a second
|
|
1876
|
+
* session with no window row behind it, which is the very topology this replaces.
|
|
1877
|
+
*/
|
|
1878
|
+
async function divertDiscordDmToWindow(input: {
|
|
1879
|
+
readonly msg: DiscordRelayMessage;
|
|
1880
|
+
readonly auth: OpenEverywhereDiscordAuth;
|
|
1881
|
+
readonly partition: Partition;
|
|
1882
|
+
readonly receive: UnifiedReceive;
|
|
1883
|
+
}): Promise<void> {
|
|
1884
|
+
const { msg } = input;
|
|
1885
|
+
const org = deploymentOrg();
|
|
1886
|
+
const atIso = new Date().toISOString();
|
|
1887
|
+
const surfaceKey = input.partition.surfaceKey;
|
|
1888
|
+
|
|
1889
|
+
// The seat: create / rotate / resume the DM's own standing window row, keyed on the SURFACE KEY
|
|
1890
|
+
// (what the token and the row are both keyed on, and what both admission gates match byte-exactly
|
|
1891
|
+
// to fence a DM window to its own DM). NO `legacyChannelId`: the OPE-90 `oe-dm-seat::<id>` rows
|
|
1892
|
+
// only ever existed on Slack, so there is nothing for the adopt-from-legacy probe to find here โ
|
|
1893
|
+
const seat = await getOrCreateDmSeat(org, { surfaceKey }, atIso);
|
|
1894
|
+
|
|
1895
|
+
// PER-WAKE TRUST off the SAME auth the session binds with; `principalFallback` because a DM
|
|
1896
|
+
// partner is the seat's principal (the native DM path's `direct` default, preserved).
|
|
1897
|
+
const trust = resolveWakeTrustFromContext(ctxFromAuth(input.auth), { id: msg.author.id, isBot: msg.author.bot }, { principalFallback: true });
|
|
1898
|
+
const wakeId = mintWakeId();
|
|
1899
|
+
|
|
1900
|
+
// The frozen reply target: the DM channel itself, host-extracted from the verified relay payload.
|
|
1901
|
+
// `dm: true` is the POSITIVE discriminant OPE-315 added โ never a guildless guild-shape โ so this
|
|
1902
|
+
// is precisely the origin the registered Discord codec's DM branch admits, and there is no path
|
|
1903
|
+
// from it to guild authority (no guildId, no autoThreadName: the codec forbids both, and the
|
|
1904
|
+
// reply leg's auto-thread branch writes guild-keyed engagement rows).
|
|
1905
|
+
//
|
|
1906
|
+
// ๐ด NO `messageId`, DELIBERATELY. It is codec-legal (cosmetic `message_reference` anchoring with
|
|
1907
|
+
// no guild-shaped consequence), but the native DM path replied with a PLAIN post, and anchoring
|
|
1908
|
+
// every answer to the question it answers is a visible change to a 1:1 conversation that this
|
|
1909
|
+
// ticket is not the place to make. Same reasoning as the Slack DM divert setting no
|
|
1910
|
+
// `replyAnchorTs`: behaviour preserved across the re-seat, not quietly improved.
|
|
1911
|
+
const origin: UnifiedWakeOrigin = { platform: "discord", channelId: msg.channelId, dm: true };
|
|
1912
|
+
|
|
1913
|
+
const rendered = renderWake(
|
|
1914
|
+
buildDiscordDmWake({
|
|
1915
|
+
channelId: msg.channelId,
|
|
1916
|
+
wakeId,
|
|
1917
|
+
...(seat.freshGeneration && seat.tail.length > 0 ? { tail: seat.tail } : {}),
|
|
1918
|
+
trigger: {
|
|
1919
|
+
id: msg.id || "unknown",
|
|
1920
|
+
author: msg.author.username ?? msg.author.id,
|
|
1921
|
+
authorId: msg.author.id,
|
|
1922
|
+
sentAt: atIso,
|
|
1923
|
+
text: msg.content,
|
|
1924
|
+
trust,
|
|
1925
|
+
},
|
|
1926
|
+
}),
|
|
1927
|
+
);
|
|
1928
|
+
try {
|
|
1929
|
+
await divertToUnifiedWindow(input.receive, {
|
|
1930
|
+
message: rendered.message,
|
|
1931
|
+
...(rendered.context && rendered.context.length > 0 ? { context: rendered.context } : {}),
|
|
1932
|
+
token: seat.token,
|
|
1933
|
+
// `vendorTs` (OPE-1716): the message snowflake's embedded creation time โ Discord's own
|
|
1934
|
+
// clock to the millisecond, and the first latency anchor. Absent id โ no reading.
|
|
1935
|
+
window: { wakeId, origin, addressing: "direct-mention", ...spreadVendorTs(discordVendorEventTime(msg.id)) } satisfies UnifiedWindowWake,
|
|
1936
|
+
// Byte-exactly the surface named inside the token โ the self-consistency both admission gates
|
|
1937
|
+
// match on, and the whole privacy fence (a DM window is deliverable to ITS OWN DM and nowhere
|
|
1938
|
+
// else). NOT domainKeyOf: a DM has no parent container to widen to.
|
|
1939
|
+
surfaceKey,
|
|
1940
|
+
// OPE-466 CARRIAGE: one ingested message, so the join runs over the trigger's own tier.
|
|
1941
|
+
// OPE-489: the author leg now reads the POSITIVE refutation, and a DM has no room leg at all
|
|
1942
|
+
// (`dm: true` โ on a DM the trust plane asks about the PARTNER, not about the room). Discord
|
|
1943
|
+
// cannot refute a DM partner: `member.pending` is a GUILD screening fact and a DM has no guild.
|
|
1944
|
+
auth: stampNonMemberChatAuth(input.auth as unknown as { attributes?: Record<string, unknown> }, [{ trust }], { dm: true }),
|
|
1945
|
+
});
|
|
1946
|
+
} catch (err) {
|
|
1947
|
+
// OPE-970: this DM divert has NO re-delivery leg (unlike slack-front.ts's DM route) and, until
|
|
1948
|
+
// now, NO audit row either โ the outer `dispatchTurn(...).catch` at the route only logged the
|
|
1949
|
+
// error to the console. `auditSupervisorFailure` (the same generic writer github.ts/linear.ts
|
|
1950
|
+
// already use for their own lifecycle failures) gives it the SAME `surface.wake_failed` row
|
|
1951
|
+
// Slack/Teams already write for their own wake failures, then rethrows unchanged.
|
|
1952
|
+
// `surface: surfaceKey` (the compound `discord/user/channel`), matching this file's own
|
|
1953
|
+
// dominant convention for a wake-shaped audit row (line ~2112/2259/2330 below), not the raw
|
|
1954
|
+
// `partition.surface` channel id.
|
|
1955
|
+
await auditSupervisorFailure({ org, platform: input.partition.platform, workspace: input.partition.workspace, surface: surfaceKey }, "surface.wake_failed", {
|
|
1956
|
+
message: String(err instanceof Error ? err.message : err).slice(0, 500),
|
|
1957
|
+
wakeId,
|
|
1958
|
+
addressing: "direct-mention",
|
|
1959
|
+
});
|
|
1960
|
+
throw err;
|
|
1961
|
+
}
|
|
1962
|
+
// Record the wake on the seat's own row (turnCount/lastWakeAt feed the rotation thresholds),
|
|
1963
|
+
// best-effort โ a touch failure must never affect the reply. Through the seat's OWN seam, so the
|
|
1964
|
+
// row it touches cannot drift from the row the mint wrote.
|
|
1965
|
+
await touchDmSeat(org, surfaceKey, atIso).catch(() => undefined);
|
|
1966
|
+
// OPE-794 โ file this DM wake on the live seat generation's tail ring, AFTER it landed, and only
|
|
1967
|
+
// the TRIGGER. The registry key is the DM window key, the same row the seat itself lives in.
|
|
1968
|
+
await recordWakeTail(org, dmWindowRegistryKey(surfaceKey), seat.token, {
|
|
1969
|
+
kind: "wake",
|
|
1970
|
+
at: atIso,
|
|
1971
|
+
from: "human", // bot authors are dropped at ingress (authForRelayMessage)
|
|
1972
|
+
trust,
|
|
1973
|
+
// THE AUTHOR GRAIN (OPE-1747) โ the same two facts this surface's taint join reads: Discord has
|
|
1974
|
+
// no membership verdict of its own beyond `member.pending`, so the OPERATOR's register query
|
|
1975
|
+
// (`authorFacts`) is the leg that carries the refutation, and it must ride the row.
|
|
1976
|
+
...tailAuthorGrain({ trust, refuted: msg.memberPending === true, ...authorFacts(msg) }),
|
|
1977
|
+
author: msg.author.username ?? msg.author.id,
|
|
1978
|
+
authorId: msg.author.id,
|
|
1979
|
+
id: msg.id || "unknown",
|
|
1980
|
+
platform: "discord",
|
|
1981
|
+
container: surfaceKey,
|
|
1982
|
+
text: msg.content,
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
/**
|
|
1987
|
+
* THE DOMAIN'S ADMISSION POLICY over a mention/name-drop hydration, for BOTH of this surface's wake roads (OPE-1800).
|
|
1988
|
+
* ๐ด 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.
|
|
1989
|
+
* ๐ด 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.
|
|
1990
|
+
* ๐ด 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.
|
|
1991
|
+
* ๐ด 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.
|
|
1992
|
+
* Nothing admitted โ `undefined`, the same shape an absent fetch produces โ never an empty slot, which renders as a quiet room.
|
|
1993
|
+
*/
|
|
1994
|
+
export function admitDiscordSeatLines(
|
|
1995
|
+
lines: readonly ContextLine[] | undefined,
|
|
1996
|
+
grain: SupervisorGrain,
|
|
1997
|
+
domainKey: string,
|
|
1998
|
+
isThread: boolean,
|
|
1999
|
+
): readonly ContextLine[] | undefined {
|
|
2000
|
+
if (!lines || lines.length === 0) return undefined;
|
|
2001
|
+
// The unseated arm hands the SAME array back, never a copy: this road was byte-identical before
|
|
2002
|
+
// OPE-1800 and must stay so.
|
|
2003
|
+
if (!grainUsesUnifiedWindow(grain)) return lines;
|
|
2004
|
+
const admitted = admitContextLines(lines, seatAdmissionFor(domainKey), isThread ? "thread" : "channel");
|
|
2005
|
+
return admitted.length > 0 ? admitted : undefined;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* The WINDOW road's use of {@link admitDiscordSeatLines}: admit, then derive the taint-join facts โ both over ONE set.
|
|
2010
|
+
* ๐ด 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.
|
|
2011
|
+
* ๐ด 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.
|
|
2012
|
+
* Nothing admitted โ EMPTY on both, the same shape an absent or empty fetch produces, never an empty context block.
|
|
2013
|
+
*/
|
|
2014
|
+
export function discordSeatContextForWindow(
|
|
2015
|
+
lines: readonly ContextLine[] | undefined,
|
|
2016
|
+
source: string | undefined,
|
|
2017
|
+
grain: SupervisorGrain,
|
|
2018
|
+
domainKey: string,
|
|
2019
|
+
isThread: boolean,
|
|
2020
|
+
): { readonly lines: readonly ContextLine[]; readonly source?: string; readonly ingestion: ReadonlyArray<ReturnType<typeof contextLineIngestion>> } {
|
|
2021
|
+
const admitted = admitDiscordSeatLines(lines, grain, domainKey, isThread);
|
|
2022
|
+
if (!admitted) return { lines: [], ingestion: [] };
|
|
2023
|
+
return { lines: admitted, ...(source === undefined ? {} : { source }), ingestion: admitted.map(contextLineIngestion) };
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
/**
|
|
2027
|
+
* Divert one Discord wake into the unified window: get-or-create the DOMAIN session (the SAME
|
|
2028
|
+
* registry row + rotation policy every other surface's live deps drive โ github.ts
|
|
2029
|
+
* liveGitHubSupervisorDeps โ so a Discord wake lands in whatever generation the domain is on),
|
|
2030
|
+
* build + render the wake with a host-minted wake-id, and hand it to the shared
|
|
2031
|
+
* divertToUnifiedWindow (which stamps the auth and registers the durable wakeIdโorigin map
|
|
2032
|
+
* entry BEFORE the send, inside unified.ts's fail-closed receive).
|
|
2033
|
+
*
|
|
2034
|
+
* ๐ด NOTHING HERE REACTS TO A SESSION THAT DIES AT TURN TIME, and nothing can: the dispatch awaits
|
|
2035
|
+
* only the ENQUEUE, so a turn-time throw cannot reject it, and eve emits no userland event for the
|
|
2036
|
+
* unreadable-World class (channels/session-resume.ts holds the measurement). This comment used to
|
|
2037
|
+
* promise a force-rotate-and-re-deliver arm; OPE-174 removed that arm as provably inert. The
|
|
2038
|
+
* remedy that does work is PRE-DISPATCH โ the OPE-599 runtime-change claim below, which rotates
|
|
2039
|
+
* BEFORE the wake goes out so the trigger lands in a session the running eve build can read.
|
|
2040
|
+
*/
|
|
2041
|
+
/**
|
|
2042
|
+
* EXPORTED FOR THE INGRESS CONTRACT TEST (OPE-1719), not for another caller. `dispatchTurn` is the
|
|
2043
|
+
* only production caller and it is one file away. `scripts/owned-thread-ingress-wiring-test.ts`
|
|
2044
|
+
* drives THIS function โ the real Discord ingress divert, over the real store โ because a test that
|
|
2045
|
+
* reimplemented the wake dep would stay green after somebody moved this leg back to the raw divert,
|
|
2046
|
+
* which is the exact regression the ticket closes.
|
|
2047
|
+
*/
|
|
2048
|
+
export async function divertDiscordWakeToUnifiedWindow(input: {
|
|
2049
|
+
readonly msg: DiscordRelayMessage;
|
|
2050
|
+
readonly auth: OpenEverywhereDiscordAuth;
|
|
2051
|
+
readonly partition: Partition;
|
|
2052
|
+
/** Guaranteed `unified` + `singleWindow` + `domainName` by the caller's gate. */
|
|
2053
|
+
readonly grain: SupervisorGrain;
|
|
2054
|
+
readonly addressing: "direct-mention" | "channel-watch" | "name-drop";
|
|
2055
|
+
/**
|
|
2056
|
+
* The caller's OWN `discordSingleWindowPrivacyOk` verdict โ an operator-VOUCHED guild surface. It
|
|
2057
|
+
* is the same fact the trigger's trust tier is resolved from, and the catch-up's per-author trust
|
|
2058
|
+
* resolver reads it rather than hardcoding one (channels/seat-catch-up.ts `surfaceInternal` says
|
|
2059
|
+
* why). Today the only production caller gates this whole divert on it, so it is always `true`
|
|
2060
|
+
* there; passing it keeps that a STRUCTURAL fact rather than a convention a second caller could
|
|
2061
|
+
* break, since this function is exported.
|
|
2062
|
+
*/
|
|
2063
|
+
readonly privacyOk: boolean;
|
|
2064
|
+
/** The ENROLMENT fact for a judged wake โ see {@link DiscordUnifiedWakeInput.threadEngaged}. The
|
|
2065
|
+
* caller reads it off the engagement row it already fetched for the dispatch gate. */
|
|
2066
|
+
readonly threadEngaged?: boolean;
|
|
2067
|
+
readonly receive: UnifiedReceive;
|
|
2068
|
+
/** The ADMITTED backscroll lines, STRUCTURED, possibly empty (OPE-1749). */
|
|
2069
|
+
readonly contextLines: readonly ContextLine[];
|
|
2070
|
+
/** `renderContext`'s provenance label for {@link contextLines}. */
|
|
2071
|
+
readonly contextSource?: string;
|
|
2072
|
+
/** OPE-475: the taint join's per-line facts for the SAME backscroll lines `contextLines` holds
|
|
2073
|
+
* ({@link gradeDiscordContextLines} output, mapped via `contextLineIngestion`) โ carried
|
|
2074
|
+
* alongside them because the join is the CALLER's to build: the render drops the envelope's own
|
|
2075
|
+
* copies, whose bytes still entered through those siblings. Possibly empty. */
|
|
2076
|
+
readonly contextIngestion: ReadonlyArray<ReturnType<typeof contextLineIngestion>>;
|
|
2077
|
+
}): Promise<void> {
|
|
2078
|
+
const { msg, grain } = input;
|
|
2079
|
+
const org = deploymentOrg();
|
|
2080
|
+
const store = getOperationalStore();
|
|
2081
|
+
const atIso = new Date().toISOString();
|
|
2082
|
+
|
|
2083
|
+
// ENGAGE an addressed THREAD wake at dispatch โ the native path's exact affordance (its
|
|
2084
|
+
// dispatch marks the arriving surface engaged) that the divert otherwise skips, so a mention
|
|
2085
|
+
// inside an existing thread (incl. a post-stop revive) keeps its non-mention follow-ups
|
|
2086
|
+
// flowing. Base channels deliberately excluded: one mention must not turn a whole channel
|
|
2087
|
+
// into a followed surface โ a base-channel mention's follow surface is the auto-thread the
|
|
2088
|
+
// reply leg opens (engaged there, unified-reply.ts). Best-effort, never blocks the wake.
|
|
2089
|
+
if (input.addressing === "direct-mention" && isThreadChannelType(msg.channelType)) {
|
|
2090
|
+
await markThreadEngaged(input.partition, msg.channelId, atIso, { participating: true });
|
|
2091
|
+
}
|
|
2092
|
+
const domainName = grain.domainName ?? "unknown"; // caller-guaranteed present; belt-and-braces
|
|
2093
|
+
const tokenFor = (gen: number) => mintRotationToken(grain, gen, () => unifiedSupervisorToken(domainName, gen));
|
|
2094
|
+
|
|
2095
|
+
// OPE-599: claim the runtime-change rotation BEFORE the row read, and unconditionally (the mint
|
|
2096
|
+
// arm must consume it too). Why, and why here: channels/runtime-rotation.ts.
|
|
2097
|
+
const runtimeChanged = await claimRuntimeRotation({ org, registryKey: grain.registryKey, surfaceKey: input.partition.surfaceKey, domainName: grain.domainName, atIso });
|
|
2098
|
+
|
|
2099
|
+
// Get-or-create the domain session (H7c self-heal semantics mirrored from github.ts: a resume
|
|
2100
|
+
// whose first wake never delivered is still "fresh", so the next wake re-carries context).
|
|
2101
|
+
const existing = await store.getChannelSession(org, grain.registryKey);
|
|
2102
|
+
let session: { token: string; generation: number; fresh: boolean; tail: readonly WakeMessageInput[] };
|
|
2103
|
+
if (!existing) {
|
|
2104
|
+
const row = await store.rotateChannelSession(org, grain.registryKey, tokenFor(1), atIso);
|
|
2105
|
+
// OPE-1821: stamp the pair onto the row this build just minted. Without it the seat's next
|
|
2106
|
+
// wake reads NULL, takes the ADOPTED arm and never rotates for the deploy it crossed.
|
|
2107
|
+
await stampMintedRuntime({ org, registryKey: grain.registryKey, surfaceKey: input.partition.surfaceKey, domainName: grain.domainName, atIso });
|
|
2108
|
+
session = { token: row.token, generation: row.generation, fresh: true, tail: [] };
|
|
2109
|
+
} else if (shouldRotate(existing, atIso, { runtimeChanged })) {
|
|
2110
|
+
// OPE-794: read the OUTGOING generation's recorded tail BEFORE minting its successor.
|
|
2111
|
+
const tail = await carryTailForward(org, grain.registryKey, existing.token);
|
|
2112
|
+
const row = await store.rotateChannelSession(org, grain.registryKey, tokenFor(existing.generation + 1), atIso);
|
|
2113
|
+
session = { token: row.token, generation: row.generation, fresh: true, tail };
|
|
2114
|
+
} else {
|
|
2115
|
+
// The tail is empty on a resume: it was carried and cleared at the rotation that minted this row.
|
|
2116
|
+
session = { token: existing.token, generation: existing.generation, fresh: existing.firstWakeAt == null, tail: [] };
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// PER-WAKE TRUST (identity/wake-trust.ts โ audit-wedge F3), off the SAME auth capture the
|
|
2120
|
+
// session binds with. TRIGGER: the message author IS the bound principal.
|
|
2121
|
+
const trust = resolveWakeTrustFromContext(ctxFromAuth(input.auth), { id: msg.author.id, isBot: msg.author.bot }, { principalFallback: true });
|
|
2122
|
+
|
|
2123
|
+
const doWake = async (token: string, generation: number, tail: readonly WakeMessageInput[], catchUpUnavailable?: string): Promise<void> => {
|
|
2124
|
+
const wakeId = mintWakeId();
|
|
2125
|
+
// The frozen reply target: the EXACT conversation the message arrived in (a thread is its
|
|
2126
|
+
// own channel id, so a threaded trigger's reply threads for free). Host-extracted from the
|
|
2127
|
+
// verified relay payload, never model-authored. guildId is caller-guaranteed by the privacy
|
|
2128
|
+
// floor (DMs never divert) โ and re-validated fail-closed by isUnifiedWakeOrigin on both
|
|
2129
|
+
// the register and the resolve side.
|
|
2130
|
+
const origin: UnifiedWakeOrigin = {
|
|
2131
|
+
platform: "discord",
|
|
2132
|
+
channelId: msg.channelId,
|
|
2133
|
+
guildId: msg.guildId ?? "",
|
|
2134
|
+
...(msg.id ? { messageId: msg.id } : {}),
|
|
2135
|
+
// ADDRESSED mention in a base channel โ carry the auto-thread name so the reply leg
|
|
2136
|
+
// opens a thread off the mention and answers there (the native path's auto-thread UX;
|
|
2137
|
+
// the divert skips that block, so without this the answer lands bare in the channel).
|
|
2138
|
+
...(msg.id && input.addressing === "direct-mention" && shouldAutoThread(msg.channelType, autoThreadEnabled())
|
|
2139
|
+
? { autoThreadName: deriveThreadName(msg.content) }
|
|
2140
|
+
: {}),
|
|
2141
|
+
// OPE-1719 โ THE POSITIVE THREAD SIGNAL the owned-thread ingress router reads. A Discord
|
|
2142
|
+
// thread is its own channel id, so `channelId` alone cannot tell a thread from a plain
|
|
2143
|
+
// channel; the relay payload's channel type can, and it is host-extracted here exactly like
|
|
2144
|
+
// every other field of this frozen origin. Stamped ONLY on a real thread type, so a plain
|
|
2145
|
+
// channel's wake stays top-level and the standing seat keeps it.
|
|
2146
|
+
...(isThreadChannelType(msg.channelType) ? { thread: true as const } : {}),
|
|
2147
|
+
};
|
|
2148
|
+
const rendered = renderWake(
|
|
2149
|
+
buildDiscordUnifiedWake({
|
|
2150
|
+
guildId: msg.guildId ?? "",
|
|
2151
|
+
channelId: msg.channelId,
|
|
2152
|
+
parentChannelId: str(input.auth.attributes.parent_channel_id),
|
|
2153
|
+
isThread: isThreadChannelType(msg.channelType),
|
|
2154
|
+
addressing: input.addressing,
|
|
2155
|
+
threadEngaged: input.threadEngaged,
|
|
2156
|
+
wakeId,
|
|
2157
|
+
engagementTier: discordEngagementTier(domainKeyOf(input.partition)),
|
|
2158
|
+
tail,
|
|
2159
|
+
// ๐ด THE HYDRATION RIDES THE WAKE, NOT A PRE-RENDERED STRING (OPE-1749). `renderWake` holds
|
|
2160
|
+
// both slots โ these lines and `tail` โ so core de-duplicates them against each other and
|
|
2161
|
+
// spends the cap below that de-duplication, the same road every other surface takes.
|
|
2162
|
+
contextLines: input.contextLines,
|
|
2163
|
+
contextSource: input.contextSource,
|
|
2164
|
+
botName: msg.botName,
|
|
2165
|
+
catchUpUnavailable,
|
|
2166
|
+
trigger: {
|
|
2167
|
+
id: msg.id || "unknown",
|
|
2168
|
+
author: msg.author.username ?? msg.author.id,
|
|
2169
|
+
authorId: msg.author.id,
|
|
2170
|
+
sentAt: atIso,
|
|
2171
|
+
text: msg.content,
|
|
2172
|
+
trust,
|
|
2173
|
+
},
|
|
2174
|
+
}),
|
|
2175
|
+
);
|
|
2176
|
+
const domainSurfaceKey = domainKeyOf(input.partition);
|
|
2177
|
+
try {
|
|
2178
|
+
// THE OWNED-THREAD INGRESS SEAM (ยง2.6, OPE-1719) โ the INBOUND variant, the one Slack's front
|
|
2179
|
+
// route has always used. A wake landing in a thread a live task worker or thread seat OWNS is
|
|
2180
|
+
// dispatched to that owner's token instead of the standing seat's. Flag-off, and on every
|
|
2181
|
+
// unowned thread, it is a byte-identical passthrough to `divertToUnifiedWindow`. The DM leg
|
|
2182
|
+
// above stays on the raw divert on purpose: a Discord DM has no threads, so it can never own
|
|
2183
|
+
// one, and a lookup there would only cost a store read.
|
|
2184
|
+
await divertInboundToUnifiedWindow(input.receive, {
|
|
2185
|
+
message: rendered.message,
|
|
2186
|
+
// ONE SOURCE for the block now: `renderWake` rendered it from `contextLines` above.
|
|
2187
|
+
context: rendered.context ?? [],
|
|
2188
|
+
token,
|
|
2189
|
+
window: {
|
|
2190
|
+
wakeId,
|
|
2191
|
+
origin,
|
|
2192
|
+
// ADDRESSED @mention โ stamp `oe_addressing:"direct-mention"` (via stampUnifiedWindowAuth)
|
|
2193
|
+
// so the unified completion handler bridges the answer to this wake if the model writes
|
|
2194
|
+
// prose instead of calling `reply`. Watch/name-drop wakes omit it (prose stays dropped).
|
|
2195
|
+
...(input.addressing === "direct-mention" ? { addressing: "direct-mention" as const } : {}),
|
|
2196
|
+
// `vendorTs` (OPE-1716): the snowflake's embedded creation time โ see the DM leg above.
|
|
2197
|
+
...spreadVendorTs(discordVendorEventTime(msg.id)),
|
|
2198
|
+
} satisfies UnifiedWindowWake,
|
|
2199
|
+
// The DOMAIN-grain key (a thread/forum-post resolves to its parent channel via domainKeyOf),
|
|
2200
|
+
// NOT the raw thread surfaceKey โ the unified receive gate re-checks crossSurfaceWindow with
|
|
2201
|
+
// an EXACT channels.includes match, and a thread id is never a listed channel. Passing the
|
|
2202
|
+
// thread grain here refuses the wake fail-closed AFTER native was suppressed = orphaned
|
|
2203
|
+
// silence (100% broken in forum channels, where every post is a thread). Identical to
|
|
2204
|
+
// surfaceKey for base channels, so their behaviour is byte-unchanged.
|
|
2205
|
+
surfaceKey: domainSurfaceKey,
|
|
2206
|
+
// OPE-466/OPE-475 CARRIAGE, over the TRIGGER *AND* the backscroll siblings via
|
|
2207
|
+
// `input.contextIngestion` โ the caller graded them with {@link gradeDiscordContextLines}
|
|
2208
|
+
// (the SAME resolution the trigger uses) before rendering `input.context`, so the join reads
|
|
2209
|
+
// the SAME facts the render was built from.
|
|
2210
|
+
//
|
|
2211
|
+
// ๐ด DISCORD'S AUTHOR LEG COMES FROM OUR OWN CONFIG (OPE-516). Discord has no organisation
|
|
2212
|
+
// concept, so `discordTrust` can NEVER positively refute an author โ `member.pending` is the
|
|
2213
|
+
// one platform signal and it is a SCREENING verdict, not a membership one. So the author leg
|
|
2214
|
+
// is the AUTHOR-TRUST REGISTER (identity/author-trust.ts): on a guild the operator has armed,
|
|
2215
|
+
// an author they did not declare grades `external`. On an UNARMED guild it contributes
|
|
2216
|
+
// nothing, and the room declaration remains the whole statement, exactly as under OPE-489.
|
|
2217
|
+
auth: stampNonMemberChatAuth(
|
|
2218
|
+
input.auth as unknown as { attributes?: Record<string, unknown> },
|
|
2219
|
+
[{ trust, refuted: msg.memberPending === true, ...authorFacts(msg) }, ...input.contextIngestion],
|
|
2220
|
+
{ surfaceKey: domainSurfaceKey },
|
|
2221
|
+
),
|
|
2222
|
+
});
|
|
2223
|
+
} catch (err) {
|
|
2224
|
+
// OPE-970: same gap as the DM divert above โ this leg's outer catch (below, `dispatchTurn`'s
|
|
2225
|
+
// caller) only logged to the console. Audit it at the domain surface the wake actually targeted.
|
|
2226
|
+
await auditSupervisorFailure(
|
|
2227
|
+
{ org: deploymentOrg(), platform: input.partition.platform, workspace: input.partition.workspace, surface: domainSurfaceKey },
|
|
2228
|
+
"surface.wake_failed",
|
|
2229
|
+
{ message: String(err instanceof Error ? err.message : err).slice(0, 500), wakeId, addressing: input.addressing },
|
|
2230
|
+
);
|
|
2231
|
+
throw err;
|
|
2232
|
+
}
|
|
2233
|
+
};
|
|
2234
|
+
|
|
2235
|
+
// A wake failure PROPAGATES (the caller's catch owns it). OPE-174 removed a
|
|
2236
|
+
// terminal-session-death rotate-and-redeliver branch here: it could never fire โ a
|
|
2237
|
+
// conversation-mode session PARKS at eve's token bound rather than failing, and a turn-time
|
|
2238
|
+
// error cannot reject the awaited enqueue anyway (core's agent/channels/session-park.ts is the
|
|
2239
|
+
// reachable handler).
|
|
2240
|
+
// ๐ด EVERY WAKE READS THE ROOM SINCE THE SEAT LAST LOOKED (OPE-1695 (b), brought to Discord by
|
|
2241
|
+
// OPE-1726). A FRESH generation renders its carried tail, as it always did; an ONGOING generation
|
|
2242
|
+
// reads what the channel said since its own `lastWakeAt`, through the ONE shared catch-up path
|
|
2243
|
+
// core holds โ same bounds, same admission policy, same cap as Slack's. The read is bounded BELOW
|
|
2244
|
+
// by the seat's last wake and ABOVE by the trigger, so a seat quiet for weeks pays for the recent
|
|
2245
|
+
// tail and nothing more. FAIL-SOFT: the shared path never throws, and an unregistered fetch comes
|
|
2246
|
+
// back as the UNAVAILABLE note rather than as a silent empty context.
|
|
2247
|
+
const catchUp = session.fresh
|
|
2248
|
+
? undefined
|
|
2249
|
+
: await seatCatchUp(
|
|
2250
|
+
"discord",
|
|
2251
|
+
{
|
|
2252
|
+
// The CONTAINER is the standing channel (a thread's PARENT when the auth carries one);
|
|
2253
|
+
// the CONVERSATION is the thread, whose own channel id IS its read endpoint on Discord.
|
|
2254
|
+
container: (isThreadChannelType(msg.channelType) ? str(input.auth.attributes.parent_channel_id) : undefined) ?? msg.channelId,
|
|
2255
|
+
...(isThreadChannelType(msg.channelType) ? { threadRoot: msg.channelId } : {}),
|
|
2256
|
+
sinceMs: seatCatchUpSinceMs(existing?.lastWakeAt, Date.parse(atIso) || Date.now(), DISCORD_RAW_FETCH_REACHBACK_HOURS),
|
|
2257
|
+
untilMs: Date.parse(atIso) || Date.now(),
|
|
2258
|
+
...(msg.id ? { excludeId: msg.id } : {}),
|
|
2259
|
+
channelKey: domainKeyOf(input.partition),
|
|
2260
|
+
// The caller's OWN vouch verdict โ the same fact the trigger's tier was resolved from.
|
|
2261
|
+
surfaceInternal: input.privacyOk,
|
|
2262
|
+
},
|
|
2263
|
+
{
|
|
2264
|
+
trustFor: backscrollTrustResolver({ surfaceInternal: input.privacyOk, channelKey: domainKeyOf(input.partition) }),
|
|
2265
|
+
// No `admit` (OPE-1857): the shared road resolves the domain's policy from the
|
|
2266
|
+
// `channelKey` the request above already carries โ the same key this line would have used.
|
|
2267
|
+
},
|
|
2268
|
+
);
|
|
2269
|
+
await doWake(session.token, session.generation, session.fresh ? session.tail : (catchUp?.messages ?? []), catchUp?.unavailable);
|
|
2270
|
+
await store.touchChannelSession(org, grain.registryKey, atIso).catch(() => undefined);
|
|
2271
|
+
// OPE-794 โ file this wake on the live generation's tail ring, AFTER it landed, and only the
|
|
2272
|
+
// TRIGGER: never the backscroll rendered beside it. That is what stops a tail inheriting a tail
|
|
2273
|
+
// (core's channels/session-tail.ts). Same registry key the rotation read and the spend counter
|
|
2274
|
+
// use, so all three target one row.
|
|
2275
|
+
await recordWakeTail(org, grain.registryKey, session.token, {
|
|
2276
|
+
kind: "wake",
|
|
2277
|
+
at: atIso,
|
|
2278
|
+
from: "human", // bot authors are dropped at ingress (authForRelayMessage)
|
|
2279
|
+
trust,
|
|
2280
|
+
// THE AUTHOR GRAIN (OPE-1747) โ byte-for-byte the facts this route's own `stampNonMemberChatAuth`
|
|
2281
|
+
// join reads for the trigger, resolved through the one core road.
|
|
2282
|
+
...tailAuthorGrain({ trust, refuted: msg.memberPending === true, ...authorFacts(msg) }),
|
|
2283
|
+
author: msg.author.username ?? msg.author.id,
|
|
2284
|
+
authorId: msg.author.id,
|
|
2285
|
+
id: msg.id || "unknown",
|
|
2286
|
+
platform: "discord",
|
|
2287
|
+
container: input.partition.surfaceKey,
|
|
2288
|
+
text: msg.content,
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
/** The `send` shape this channel dispatches with (SendFn parametrized by our state). The first
|
|
2293
|
+
* arg is a plain string OR eve's SendPayload `{ message, context }` โ `context` strings are
|
|
2294
|
+
* appended as user messages before the delivery turn (used for the wake/mention backscroll). */
|
|
2295
|
+
export type DiscordGatewaySend = (
|
|
2296
|
+
message: string | { readonly message: string; readonly context?: readonly string[] },
|
|
2297
|
+
options: {
|
|
2298
|
+
auth: OpenEverywhereDiscordAuth;
|
|
2299
|
+
continuationToken: string;
|
|
2300
|
+
state: DiscordGatewayState;
|
|
2301
|
+
},
|
|
2302
|
+
) => Promise<unknown>;
|
|
2303
|
+
|
|
2304
|
+
/**
|
|
2305
|
+
* Resolve the wake decision (consulting the engagement registry only when needed) and, if it
|
|
2306
|
+
* passes, start/resume the durable session. A mention also ENGAGES the channel so later
|
|
2307
|
+
* non-mention replies keep waking it (reply-without-re-tagging, exactly like Slack). Runs in
|
|
2308
|
+
* the background (handed to waitUntil) so the route acks fast.
|
|
2309
|
+
*/
|
|
2310
|
+
export async function dispatchTurn(
|
|
2311
|
+
msg: DiscordRelayMessage,
|
|
2312
|
+
auth: OpenEverywhereDiscordAuth,
|
|
2313
|
+
send: DiscordGatewaySend,
|
|
2314
|
+
// Injectable so the auto-thread fail-open path is testable without a live Discord call.
|
|
2315
|
+
createThread: (channelId: string, messageId: string, name: string) => Promise<string | null> = createDiscordThread,
|
|
2316
|
+
// Injectable so the threadโparent grain path is testable without a live Discord call.
|
|
2317
|
+
resolveParent: (channelId: string) => Promise<string | undefined> = threadParentChannelId,
|
|
2318
|
+
// eve's cross-channel `to` (the route supplies it) โ the SINGLE-WINDOW divert primitive.
|
|
2319
|
+
// Optional + additive: absent (older callers/tests) โ the divert block never runs, native path.
|
|
2320
|
+
receive?: UnifiedReceive,
|
|
2321
|
+
): Promise<void> {
|
|
2322
|
+
// THREADโPARENT GRAIN (item 22). A message arriving IN an existing thread carries the
|
|
2323
|
+
// thread's own channel id, which never matches a domain listing the parent channel and
|
|
2324
|
+
// never accumulates against the parent's spend cap. Resolve the parent ONCE here (one
|
|
2325
|
+
// cached REST `GET /channels/{id}` โ discord-channel-info.ts, shared with the approval
|
|
2326
|
+
// scope labels) and stamp it onto the auth, so everything downstream (partition
|
|
2327
|
+
// domainKey, memory/RBAC/routing hops, the spend gate + hooks) stays sync. BEST-EFFORT:
|
|
2328
|
+
// an unresolvable parent (no token, REST failure) keeps today's thread-grain behaviour
|
|
2329
|
+
// โ the turn is never blocked on this. (The auto-thread path below stamps its parent
|
|
2330
|
+
// for free in dispatchSurfaceFor; this handles turns that START in a thread.)
|
|
2331
|
+
if (isThreadChannelType(msg.channelType)) {
|
|
2332
|
+
const parentChannelId = await resolveParent(msg.channelId);
|
|
2333
|
+
if (parentChannelId) {
|
|
2334
|
+
auth = buildDiscordAuth({
|
|
2335
|
+
userId: msg.author.id,
|
|
2336
|
+
guildId: msg.guildId,
|
|
2337
|
+
channelId: msg.channelId,
|
|
2338
|
+
channelType: msg.channelType,
|
|
2339
|
+
parentChannelId,
|
|
2340
|
+
// OPE-671: preserve the โ anchor โ this REBUILDS auth from scratch, which would otherwise
|
|
2341
|
+
// silently drop the messageId authForRelayMessage already stamped.
|
|
2342
|
+
messageId: msg.id,
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
const partition: Partition = resolvePartition(ctxFromAuth(auth));
|
|
2347
|
+
const key = engagementKey(msg);
|
|
2348
|
+
const isDm = isDmChannel(msg.channelType);
|
|
2349
|
+
// A DM or a mention always wakes; only a plain guild message needs the (async) engagement lookup.
|
|
2350
|
+
// ONE engagement read, and it is the ROW rather than a boolean (OPE-418) โ the row carries the
|
|
2351
|
+
// durable STOOD-DOWN marker as well as "is this engaged", and both decide the same question.
|
|
2352
|
+
// โโ THE EXTERNAL-PARTICIPANT POLICY (OPE-507 author grain, OPE-504 room grain) โโโโโโโโโโโโโโโโโโ
|
|
2353
|
+
//
|
|
2354
|
+
// POSITION IS LOAD-BEARING: this sits ABOVE the engagement read, the spend gate, every affordance
|
|
2355
|
+
// (๐, typing, auto-thread) and both window diverts. A message the agent may not act on must cost
|
|
2356
|
+
// nothing and show nothing โ an affordance on a suppressed message tells the sender the agent is
|
|
2357
|
+
// reading them, which is the opposite of the intended behaviour.
|
|
2358
|
+
//
|
|
2359
|
+
// Both grains are inert under their shipped values (`taint` / `engage`), so this whole block is a
|
|
2360
|
+
// no-op on every deployment that has not opted in.
|
|
2361
|
+
// EXPLICIT ADDRESSING โ one fact, read twice: the verdict owes a private notice on it, and the
|
|
2362
|
+
// OPE-885 ack keys on it. A DM is addressing by construction; `mentionsBot` is Discord's own
|
|
2363
|
+
// mention signal, which a NAME-DROP never sets.
|
|
2364
|
+
const addressed = isDm || msg.mentionsBot;
|
|
2365
|
+
const externalVerdict = discordExternalVerdict({
|
|
2366
|
+
policy: externalPolicyForRoom(domainKeyOf(partition)),
|
|
2367
|
+
// The SAME per-message facts the taint author leg reads (access/taint-signal.ts): the resolved
|
|
2368
|
+
// human tier plus the operator's author-trust query. Discord contributes no `refuted` โ the
|
|
2369
|
+
// platform has no organisation concept to refute against, which is why the register exists.
|
|
2370
|
+
author: {
|
|
2371
|
+
trust: resolveWakeTrustFromContext(ctxFromAuth(auth), { id: msg.author.id, isBot: msg.author.bot }, { principalFallback: true }),
|
|
2372
|
+
...authorFacts(msg),
|
|
2373
|
+
},
|
|
2374
|
+
addressed,
|
|
2375
|
+
isDm,
|
|
2376
|
+
roomKey: domainKeyOf(partition),
|
|
2377
|
+
});
|
|
2378
|
+
if (externalVerdict !== "proceed") {
|
|
2379
|
+
// BEHAVIOUR 1 โ an ADDRESSED message is owed an answer, so the sender gets a PRIVATE notice and
|
|
2380
|
+
// the agent does nothing else. BEHAVIOUR 2 โ an ambient message was owed nothing, so it is
|
|
2381
|
+
// dropped in silence. The ROOM verdict posts its once-ever announcement instead. None wakes a
|
|
2382
|
+
// model turn, and none shows an affordance.
|
|
2383
|
+
// The private notice and the once-ever room announcement both ANSWER the person who addressed
|
|
2384
|
+
// us, and both say more than the ack does โ so either suppresses it.
|
|
2385
|
+
let alreadyAnswered = false;
|
|
2386
|
+
if (externalVerdict === "withheld-addressed") alreadyAnswered = await postExternalSenderNotice(msg.author.id).catch(() => false);
|
|
2387
|
+
if (externalVerdict === "silent-room") alreadyAnswered = await announceSilentRoom(partition.surfaceKey, msg.channelId).catch(() => false);
|
|
2388
|
+
// THE MENTION ACK (OPE-885): an explicit @mention or DM that got no private answer gets the one
|
|
2389
|
+
// fixed host line, in the channel it was said in. An AMBIENT or NAME-DROP suppression stays
|
|
2390
|
+
// fully dark โ `addressed` is false for both, and this is the only gate.
|
|
2391
|
+
const acked =
|
|
2392
|
+
owesSuppressedMentionAck({ addressed, alreadyAnswered }) &&
|
|
2393
|
+
(await discordCreateMessage(msg.channelId, EXTERNAL_MENTION_ACK).then((id) => id !== null, () => false));
|
|
2394
|
+
await appendAudit({
|
|
2395
|
+
org: deploymentOrg(),
|
|
2396
|
+
platform: "discord",
|
|
2397
|
+
workspace: partition.workspace,
|
|
2398
|
+
surface: partition.surfaceKey,
|
|
2399
|
+
principalId: msg.author.id,
|
|
2400
|
+
action: "discord.external_policy_suppressed",
|
|
2401
|
+
detail: { verdict: externalVerdict, channelId: msg.channelId, ...suppressionRendering(acked) },
|
|
2402
|
+
}).catch(() => undefined);
|
|
2403
|
+
log(`suppressed ${partition.surfaceKey}: externalPolicy verdict "${externalVerdict}"`);
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
const engRow = isDm || msg.mentionsBot ? null : await getThreadEngagement(partition, key);
|
|
2408
|
+
const engaged = isDm || msg.mentionsBot ? true : engRow !== null;
|
|
2409
|
+
|
|
2410
|
+
// STOOD-DOWN SUPPRESSION (OPE-418), replacing the STOP REGEX that stood here. The regex matched
|
|
2411
|
+
// the word "stop" in a followed surface and DELETED the engagement row; the agent now judges the
|
|
2412
|
+
// ask itself between turns (`stand_down`) and sets a flag instead. A flag, not a delete, is what
|
|
2413
|
+
// makes this gate possible at all: a deleted row is indistinguishable from a surface the agent was
|
|
2414
|
+
// never invited into, so the old mechanism could suppress nothing here. A direct @mention is the
|
|
2415
|
+
// re-invitation and always passes โ it clears the marker via markEngaged({participating: true}).
|
|
2416
|
+
if (!isDm && !msg.mentionsBot && engRow?.stopped) {
|
|
2417
|
+
log(`suppressed ${partition.surfaceKey} โ stood down here until an explicit @mention`);
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
let nameDrop = false;
|
|
2422
|
+
if (!shouldDispatchDiscord({ mentionsBot: msg.mentionsBot, isEngaged: engaged, isDm })) {
|
|
2423
|
+
// NAME-DROP WAKE (item 25 โ Slack parity): an un-followed guild surface whose message
|
|
2424
|
+
// says the bot's display name gets a JUDGED turn (may end NO_REPLY) when the domain
|
|
2425
|
+
// opted in via `nameDropWake` โ checked at DOMAIN grain (domainKeyOf: a thread asks
|
|
2426
|
+
// its parent channel's domain), same fail-closed default as Slack. No thread, no ๐,
|
|
2427
|
+
// no engagement: the agent was not invited; the judged preamble owns the restraint.
|
|
2428
|
+
// ๐ด THE TWO HALVES ARE READ SEPARATELY (OPE-1899), because only the split can tell a message
|
|
2429
|
+
// that NAMED the agent from one that named nobody. `nameDropEligible` folds the name match into
|
|
2430
|
+
// one boolean, so a candidate the DOMAIN FLAG refused is the one refusal this surface can state
|
|
2431
|
+
// honestly โ and it is exactly the refusal an operator reads back when the road looks dead.
|
|
2432
|
+
const eligible = nameDropEligible({
|
|
2433
|
+
mentionsBot: msg.mentionsBot,
|
|
2434
|
+
isDm,
|
|
2435
|
+
isEngaged: engaged,
|
|
2436
|
+
// An allowlisted e2e driver counts as a user here too (item 29) โ its name-drop
|
|
2437
|
+
// scenarios must exercise the same gate a human's would. Everyone else: bot = bot.
|
|
2438
|
+
authorIsBot: msg.author.bot && !isAllowedDriver(msg.guildId, msg.author.id),
|
|
2439
|
+
channelType: msg.channelType,
|
|
2440
|
+
content: msg.content,
|
|
2441
|
+
botName: msg.botName,
|
|
2442
|
+
});
|
|
2443
|
+
nameDrop = eligible && nameDropWakeEnabled(domainKeyOf(partition), getConfig().loadDomains());
|
|
2444
|
+
if (!nameDrop) {
|
|
2445
|
+
// A candidate the flag refused writes the road's SILENT row; a message that named nobody
|
|
2446
|
+
// writes nothing, which is the volume bound channels/name-drop-wake-audit.ts states.
|
|
2447
|
+
if (eligible) {
|
|
2448
|
+
await auditNameDropSilent(nameDropAuditKey(partition, msg), "nameDropWake not enabled for this channel's domain");
|
|
2449
|
+
}
|
|
2450
|
+
log(`drop ${partition.surfaceKey}: non-mention in a channel the agent isn't following`);
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
// A name-drop IN A THREAD engages the thread โ Slack's post-name-drop grain (its
|
|
2454
|
+
// reply thread is followed), reached deterministically at dispatch (an engage-on-post
|
|
2455
|
+
// state seed is dropped whenever this send RESUMES an old thread session, e.g. one of
|
|
2456
|
+
// our own auto-threads whose engagement lapsed). A thread is a small, low-traffic
|
|
2457
|
+
// scope and the stop verb above is the undo. BASE-CHANNEL name-drops deliberately do
|
|
2458
|
+
// NOT engage โ that would put a judged model turn on every subsequent channel
|
|
2459
|
+
// message (the engaged-channel surprise from live testing). Best-effort.
|
|
2460
|
+
if (isThreadChannelType(msg.channelType)) {
|
|
2461
|
+
await markThreadEngaged(partition, key, new Date().toISOString());
|
|
2462
|
+
}
|
|
2463
|
+
// ๐ด THE POSITIVE ROW (OPE-1899) โ the one line at which this road has decided to wake. Discord
|
|
2464
|
+
// declines the wake-arm seam (channels/wake-arm.ts), so `via` is always the built-in policy here,
|
|
2465
|
+
// and this route opens the native session rather than handing off to a standing seat.
|
|
2466
|
+
await auditNameDropWoke(nameDropAuditKey(partition, msg), "name-dropped in an enabled guild surface โ judged wake", {
|
|
2467
|
+
road: "native",
|
|
2468
|
+
via: "built-in",
|
|
2469
|
+
});
|
|
2470
|
+
log(`name-drop wake on ${partition.surfaceKey} ("${msg.botName}")`);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
// SPEND CAP (pre-flight โ the ONE shared gate, spend/ingress-gate.ts, the same function every
|
|
2474
|
+
// other surface and the cross-surface divert call): a scope over its monthly budget DECLINES the
|
|
2475
|
+
// turn with a VISIBLE refusal, never a silent drop, before any affordance (reaction/thread/
|
|
2476
|
+
// typing) is shown. The CHECK fails OPEN (a broken store must never take a team's agent off the
|
|
2477
|
+
// air); a definite OVER fails CLOSED โ unless the sender is an admin over this scope, in which
|
|
2478
|
+
// case the turn proceeds past the cap with a visible, hourly-throttled โ ๏ธ plus an audit row (sudo
|
|
2479
|
+
// semantics: the guardrail binds the team, never locks out the budget owner; spend/bypass.ts).
|
|
2480
|
+
// Discord is the surface whose admin evidence has TWO legs โ the config roster and the
|
|
2481
|
+
// REST-verified guild administrator, reached through the registered native-admin check, which is
|
|
2482
|
+
// the reference implementation for that seam. DMs are NOT exempt (OPE-267 deleted the exemption);
|
|
2483
|
+
// the gate picks the DM refusal variant itself, because "DM me to propose an override" is a loop
|
|
2484
|
+
// with no exit when the refusal IS the DM.
|
|
2485
|
+
//
|
|
2486
|
+
// POSITION IS THE LOAD-BEARING PART, and Discord's has always been right: this gate runs BEFORE
|
|
2487
|
+
// the single-window diversion below, so a Discord wake is decided here and never reaches the
|
|
2488
|
+
// window ungated. Teams/GitHub/Linear were the opposite โ their window leg returned BEFORE their
|
|
2489
|
+
// native gate, which is the hole the divert-side gate (channels/unified.ts, door "window")
|
|
2490
|
+
// exists to close. Both of Discord's doors now render the SAME refusal copy; while this block was
|
|
2491
|
+
// hand-rolled they disagreed on one clause, so which door declined you decided which sentence you
|
|
2492
|
+
// read.
|
|
2493
|
+
//
|
|
2494
|
+
// WHY THE HAND-ROLLED COPY IS GONE (OPE-302). It was kept deliberately for a while โ byte-pinned
|
|
2495
|
+
// by the scenario goldens, so migrating was a behaviour change with a re-record rather than a
|
|
2496
|
+
// refactor. Two things came with the migration: the `spend.gate` audit row Discord alone never
|
|
2497
|
+
// wrote (the divergence `6687d86f` recorded), and the loss of the two `log()` lines โ deliberate,
|
|
2498
|
+
// since a webhook function's console output never reaches `vercel logs`, which is exactly why the
|
|
2499
|
+
// decision rides the audit trail instead.
|
|
2500
|
+
const spendDoor: SpendGateDoor = msg.mentionsBot || isDm ? "mention" : "engagement";
|
|
2501
|
+
if (
|
|
2502
|
+
(await ingressSpendGate({
|
|
2503
|
+
// The wake's OWN auth โ including the threadโparent stamp resolved above โ so the gate's
|
|
2504
|
+
// internal domainKeyOf lands on the SPEND grain (a thread caps against its parent channel),
|
|
2505
|
+
// which is the grain the cap gates and the key an override proposal must target. Freezing a
|
|
2506
|
+
// thread's ephemeral key here was the live 2026-07-06 bug that grain exists to fix.
|
|
2507
|
+
bindable: ctxFromAuth(auth),
|
|
2508
|
+
// Discord's ONE ingress serves both of Slack's doors, so name the one this wake used: an
|
|
2509
|
+
// @mention or a DM is the mention door; an engaged follow-up or a name-drop is the
|
|
2510
|
+
// engagement door. That distinction is the whole point of the field โ a live probe has to be
|
|
2511
|
+
// able to tell which path refused.
|
|
2512
|
+
door: spendDoor,
|
|
2513
|
+
// The author id IS the attributed principal (discord-identity.ts stamps
|
|
2514
|
+
// `principalId: userId`), so the person this turn's spend would be recorded under is the
|
|
2515
|
+
// only person whose admin standing can buy a bypass of their own bill.
|
|
2516
|
+
principalId: msg.author.id,
|
|
2517
|
+
post: async (text) => {
|
|
2518
|
+
await discordCreateMessage(msg.channelId, text);
|
|
2519
|
+
},
|
|
2520
|
+
})) === "declined"
|
|
2521
|
+
) {
|
|
2522
|
+
return; // drop โ no model turn
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// THE DM WINDOW DIVERT (OPE-356) โ a DM lands in its OWN standing window, NOT in any shared one.
|
|
2526
|
+
//
|
|
2527
|
+
// POSITION IS LOAD-BEARING AND IT IS HERE FOR TWO REASONS. It sits AFTER the spend gate, so a DM
|
|
2528
|
+
// is decided at Discord's own door and never reaches a window ungated (the property the comment
|
|
2529
|
+
// above calls Discord's, kept). And it sits BEFORE the single-window block, which is not a race:
|
|
2530
|
+
// that block's privacy floor refuses a guildless surface UNCONDITIONALLY, so a DM could never
|
|
2531
|
+
// enter a shared window either way โ the ordering just says out loud which window a DM belongs
|
|
2532
|
+
// to, rather than leaving it to a `return false` two blocks down.
|
|
2533
|
+
//
|
|
2534
|
+
// BOTH DM SHAPES, one predicate ({@link isDmChannel}: type 1 and type 3). Slack first-classed 1:1
|
|
2535
|
+
// `im` at stage 2 and its group DM (`mpim`) separately at stage 4 (OPE-355), because its
|
|
2536
|
+
// DM-partner trust lookup is a 1:1 concept the mpim leg had to route around; Discord has no such
|
|
2537
|
+
// lookup (discordTrust reads `member.pending`, which a DM has no
|
|
2538
|
+
// member object for), and `rootDmGrainAttrs` already treats both as one standing grain. Splitting
|
|
2539
|
+
// them here would invent a Discord-internal asymmetry that nothing in the fence needs โ each is
|
|
2540
|
+
// its own surface, so each gets its own surface-keyed window and its own byte-exact fence.
|
|
2541
|
+
//
|
|
2542
|
+
// Fail-closed to the NATIVE path when there is no `receive` at all (an older caller/test) โ
|
|
2543
|
+
// byte-identical to the pre-OPE-356 per-channel DM session. The deployment-level `OE_DM_SEAT_ROTATION`
|
|
2544
|
+
// hatch that used to sit beside it is gone (OPE-368): it was the last supported way to leave a DM on
|
|
2545
|
+
// an un-wakeable native token, which is exactly the seat class the retired delegation anchor served.
|
|
2546
|
+
if (isDm && receive) {
|
|
2547
|
+
// The ๐ "seen" ack SURVIVES the re-seat โ it is the DM's only affordance and a one-shot REST
|
|
2548
|
+
// PUT with no session-coupled cleanup (a non-threaded ๐ STANDS as "seen"; only the auto-thread
|
|
2549
|
+
// case is swap-tracked, and a DM never auto-threads). Typing deliberately does NOT: its
|
|
2550
|
+
// re-triggers live on THIS channel's event handlers, which a diverted turn never reaches, so a
|
|
2551
|
+
// single ~10s burst against a minute-long turn would read as "typingโฆ then nothing" โ the
|
|
2552
|
+
// looks-like-a-crash tell the judged-wake typing gate exists to avoid.
|
|
2553
|
+
if (statusUxEnabled()) await setDiscordReaction(msg.channelId, msg.id, EYES_EMOJI, "PUT");
|
|
2554
|
+
await divertDiscordDmToWindow({ msg, auth, partition, receive });
|
|
2555
|
+
await appendAudit({
|
|
2556
|
+
org: deploymentOrg(),
|
|
2557
|
+
platform: "discord",
|
|
2558
|
+
// A DM has no guild; the binding keys its workspace to the caller's own user id (bindDiscord),
|
|
2559
|
+
// which is what `partition` already carries โ no fabricated guild id.
|
|
2560
|
+
workspace: partition.workspace,
|
|
2561
|
+
surface: partition.surfaceKey,
|
|
2562
|
+
principalId: msg.author.id,
|
|
2563
|
+
action: "discord.dispatched",
|
|
2564
|
+
detail: { addressing: "direct-mention", dmWindow: true, channelId: msg.channelId },
|
|
2565
|
+
}).catch(() => undefined);
|
|
2566
|
+
log(`dm-window divert ${partition.surfaceKey}`);
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
// SINGLE-WINDOW DIVERSION (the cross-surface shared-context window โ see the section header
|
|
2571
|
+
// above). When this surface's domain opted into crossSurfaceWindow AND the Discord privacy
|
|
2572
|
+
// floor passes (discordSingleWindowPrivacyOk โ an operator-VOUCHED guild surface; a DM never
|
|
2573
|
+
// passes), the whole wake diverts into the synthetic unified channel: an @mention as an
|
|
2574
|
+
// ADDRESSED `direct-mention` wake โ the mention policy's suppressNative IS this early return,
|
|
2575
|
+
// so none of the native affordances below (auto-thread, ๐, typing, per-channel session) run โ
|
|
2576
|
+
// and engaged/name-drop wakes as judged watch wakes. The mention still ENGAGES the surface
|
|
2577
|
+
// first (exactly as the native path does) so plain follow-ups keep waking โ and keep
|
|
2578
|
+
// diverting. Floor not passed / flags off / no receive โ fall through: byte-identical native
|
|
2579
|
+
// behaviour (fail-closed: an un-vouched surface never joins the shared window).
|
|
2580
|
+
const windowGrain = discordGrainFor(domainKeyOf(partition));
|
|
2581
|
+
if (grainUsesUnifiedWindow(windowGrain) && windowGrain.domainName !== undefined && receive) {
|
|
2582
|
+
const privacyOk = discordSingleWindowPrivacyOk({
|
|
2583
|
+
guildId: msg.guildId,
|
|
2584
|
+
workspaceKey: partition.workspaceKey,
|
|
2585
|
+
surfaceKey: partition.surfaceKey,
|
|
2586
|
+
domainKey: domainKeyOf(partition),
|
|
2587
|
+
});
|
|
2588
|
+
// The ONE shared mention decision (unified-window.ts seam via the thin Discord shim):
|
|
2589
|
+
// addressed `direct-mention` โบ the native dispatch stands down for this mention.
|
|
2590
|
+
const mentionPolicy = discordSingleWindowMentionPolicy(true, msg.mentionsBot, privacyOk);
|
|
2591
|
+
if (privacyOk) {
|
|
2592
|
+
if (msg.mentionsBot) {
|
|
2593
|
+
// A mention is an invitation โ participating engagement on THIS surface (the base
|
|
2594
|
+
// channel/thread the mention landed in; no auto-thread on the window path), mirroring
|
|
2595
|
+
// the native path's landmine fix so follow-ups wake without re-tagging.
|
|
2596
|
+
await markThreadEngaged(partition, key, new Date().toISOString(), { participating: true });
|
|
2597
|
+
}
|
|
2598
|
+
// Backscroll: same condition as the native path (mention/name-drop hydration; an engaged
|
|
2599
|
+
// follow-up's conversation already lives in the shared session). Fail-soft [].
|
|
2600
|
+
// OPE-475: fetch the RAW lines (not the pre-rendered-string wrapper) so they can be graded
|
|
2601
|
+
// with the SAME per-line resolution the native path uses ({@link gradeDiscordContextLines})
|
|
2602
|
+
// before rendering โ the render then carries the reference-only note for any demoted line,
|
|
2603
|
+
// and `contextIngestion` carries the SAME facts to the join, which never sees the lines
|
|
2604
|
+
// themselves on this leg (only the pre-rendered `context` strings reach `divertToUnifiedWindow`).
|
|
2605
|
+
const windowLines =
|
|
2606
|
+
nameDrop || msg.mentionsBot
|
|
2607
|
+
? await fetchDiscordBackscrollLines({
|
|
2608
|
+
channelId: msg.channelId,
|
|
2609
|
+
triggerMessageId: msg.id,
|
|
2610
|
+
isThread: isThreadChannelType(msg.channelType),
|
|
2611
|
+
botId: msg.botId,
|
|
2612
|
+
}).catch(() => undefined)
|
|
2613
|
+
: undefined;
|
|
2614
|
+
const gradedWindowLines = windowLines ? await gradeDiscordContextLines(windowLines.lines, ctxFromAuth(auth), msg.guildId) : undefined;
|
|
2615
|
+
// THE DOMAIN'S ADMISSION POLICY, THE RENDER AND THE JOIN FACTS, over ONE admitted set
|
|
2616
|
+
// ({@link discordSeatContextForWindow} โ OPE-1800). This is the SEAT road, so the policy runs
|
|
2617
|
+
// here and nowhere else on Discord; the native dispatch below keeps its whole hydration.
|
|
2618
|
+
const seatContext = discordSeatContextForWindow(gradedWindowLines, windowLines?.source, windowGrain, domainKeyOf(partition), isThreadChannelType(msg.channelType));
|
|
2619
|
+
const addressing = mentionPolicy.addressing ?? (nameDrop && msg.botName ? "name-drop" : "channel-watch");
|
|
2620
|
+
await divertDiscordWakeToUnifiedWindow({
|
|
2621
|
+
msg,
|
|
2622
|
+
auth,
|
|
2623
|
+
partition,
|
|
2624
|
+
grain: windowGrain,
|
|
2625
|
+
addressing,
|
|
2626
|
+
privacyOk,
|
|
2627
|
+
// The ENROLMENT fact, off the row this dispatch ALREADY read (OPE-1740). The narrow reading
|
|
2628
|
+
// every other surface states: an @mention (or an earned reply) invited the agent here, and
|
|
2629
|
+
// the invitation still stands. A warm name-drop latch (`participating: false`) is not an
|
|
2630
|
+
// invitation, and a stopped row has already been left.
|
|
2631
|
+
threadEngaged: engRow?.participating === true && engRow.stopped !== true,
|
|
2632
|
+
receive,
|
|
2633
|
+
contextLines: seatContext.lines,
|
|
2634
|
+
contextSource: seatContext.source,
|
|
2635
|
+
contextIngestion: seatContext.ingestion,
|
|
2636
|
+
});
|
|
2637
|
+
await appendAudit({
|
|
2638
|
+
org: deploymentOrg(),
|
|
2639
|
+
platform: "discord",
|
|
2640
|
+
workspace: msg.guildId ?? "unknown",
|
|
2641
|
+
surface: partition.surfaceKey,
|
|
2642
|
+
principalId: msg.author.id,
|
|
2643
|
+
action: "discord.dispatched",
|
|
2644
|
+
detail: { addressing, singleWindow: true, channelId: msg.channelId },
|
|
2645
|
+
}).catch(() => undefined);
|
|
2646
|
+
log(`single-window divert ${partition.surfaceKey} (${addressing})`);
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// AUTO-THREAD. A mention landing in a base guild text/announcement channel opens a dedicated
|
|
2652
|
+
// thread and the WHOLE turn re-keys onto it (see dispatchSurfaceFor). This stays inside the
|
|
2653
|
+
// background dispatch (waitUntil) โ one extra REST round-trip, invisible to the fast route
|
|
2654
|
+
// ack. Fail-OPEN: a null from createThread (missing permission, rate limit, API error) keeps
|
|
2655
|
+
// the base channel so the turn still runs inline โ threading never gates delivery. Only a
|
|
2656
|
+
// mention threads; a non-mention engaged wake is ALREADY in its conversation surface (a base
|
|
2657
|
+
// channel it's following, or a thread of type 10/11/12 that shouldAutoThread excludes).
|
|
2658
|
+
const statusUx = statusUxEnabled();
|
|
2659
|
+
let threadId: string | null = null;
|
|
2660
|
+
if (msg.mentionsBot && shouldAutoThread(msg.channelType, autoThreadEnabled())) {
|
|
2661
|
+
threadId = await createThread(msg.channelId, msg.id, deriveThreadName(msg.content));
|
|
2662
|
+
if (!threadId) {
|
|
2663
|
+
log(`auto-thread failed for ${partition.surfaceKey} โ falling back to an inline reply in the base channel`);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// STATUS UX: the ๐ "seen" ack โ on DIRECT addresses (mention or DM), the turns with a
|
|
2668
|
+
// guaranteed reply. On the auto-threaded mention it is swap-tracked (โ โ
when the answer
|
|
2669
|
+
// lands in the thread โ the one case where the reply is away from the message AND a fresh
|
|
2670
|
+
// session guarantees the seeded swap coords are never stale). Everywhere else it simply
|
|
2671
|
+
// STANDS as "seen" โ the answer arrives right underneath, so there's nothing to signal by
|
|
2672
|
+
// removing it, and an untracked ack is immune to the resumed-session staleness class.
|
|
2673
|
+
// Engaged non-mention wakes get NO reaction: the agent may judge silence, and eyes-ing every
|
|
2674
|
+
// message in a followed channel reads as surveillance. Best-effort: setDiscordReaction
|
|
2675
|
+
// swallows every failure โ never blocks or drops the turn.
|
|
2676
|
+
if (statusUx && (msg.mentionsBot || isDm)) {
|
|
2677
|
+
await setDiscordReaction(msg.channelId, msg.id, EYES_EMOJI, "PUT");
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
// Resolve the surface the turn actually runs on: the thread (re-keyed) when one was created,
|
|
2681
|
+
// else the base channel. Everything downstream (identity, continuation, reply state,
|
|
2682
|
+
// engagement) reads from this single descriptor so the base/thread split lives in one place.
|
|
2683
|
+
const surface = dispatchSurfaceFor(msg, auth, threadId);
|
|
2684
|
+
// When we re-keyed to a thread, engagement must be stored under the THREAD's surfaceKey (its
|
|
2685
|
+
// own silo), not the base channel's โ so re-resolve the partition from the thread auth.
|
|
2686
|
+
const dispatchPartition: Partition = threadId ? resolvePartition(ctxFromAuth(surface.auth)) : partition;
|
|
2687
|
+
|
|
2688
|
+
// Engage on mention so subsequent plain replies in this SURFACE also wake (best-effort).
|
|
2689
|
+
// On auto-thread this engages the THREAD (where the conversation lives), NOT the base channel
|
|
2690
|
+
// โ a later base-channel message needs its own mention (โ its own thread), per the plan.
|
|
2691
|
+
//
|
|
2692
|
+
// participating:true โ THE PARTICIPATING-FLAG LANDMINE FIX (mirrors slack.ts onInbound): a
|
|
2693
|
+
// mention is an INVITATION, so the surface must be an invited PARTICIPANT, not a merely-warm
|
|
2694
|
+
// watcher. Without the flag the mention left a `participating:false` row, which the new warm
|
|
2695
|
+
// auto-disengage below would then shed after a run of silent turns โ @mentioning the bot must
|
|
2696
|
+
// never make it sheddable. participating:true also resets the silent streak and exempts the
|
|
2697
|
+
// surface from the warm shed (sticky until an explicit stop).
|
|
2698
|
+
if (msg.mentionsBot) {
|
|
2699
|
+
await markThreadEngaged(dispatchPartition, surface.engagementKey, new Date().toISOString(), { participating: true });
|
|
2700
|
+
}
|
|
2701
|
+
// Assemble the state SEEDED into send (applied only when this send STARTS a session): the
|
|
2702
|
+
// reply surface follows the re-key (surface.state.channelId); the ๐โโ
reaction target is
|
|
2703
|
+
// included ONLY for the auto-threaded case (see the reaction comment above โ the one case
|
|
2704
|
+
// where the seed is guaranteed to reach a fresh session).
|
|
2705
|
+
const turnState: DiscordGatewayState = buildGatewayState({
|
|
2706
|
+
replyChannelId: surface.state.channelId,
|
|
2707
|
+
// Seed the guild so the completion handler can rebuild the engagement partition (promote on
|
|
2708
|
+
// reply / shed on silence). msg.guildId is the same for the base channel and any thread it
|
|
2709
|
+
// spawns (a thread never leaves its guild); absent for DMs โ no engagement bookkeeping.
|
|
2710
|
+
guildId: msg.guildId,
|
|
2711
|
+
original: statusUx && threadId ? { channelId: msg.channelId, messageId: msg.id } : undefined,
|
|
2712
|
+
});
|
|
2713
|
+
|
|
2714
|
+
// Working signal: fire the native typing indicator on the reply surface right away (covers
|
|
2715
|
+
// the gap until the turn's own events take over re-triggering it). Best-effort. JUDGED
|
|
2716
|
+
// wakes (engaged / name-drop โ anything not directly addressed) may end NO_REPLY, so for
|
|
2717
|
+
// those the gate holds ALL typing back until the streamed reply text proves the model is
|
|
2718
|
+
// actually answering โ a judged silence then shows NOTHING instead of "typingโฆ then
|
|
2719
|
+
// nothing" (the looks-like-a-crash tell from live testing).
|
|
2720
|
+
if (statusUx) {
|
|
2721
|
+
if (msg.mentionsBot || isDm) {
|
|
2722
|
+
openTypingGate(surface.state.channelId);
|
|
2723
|
+
await triggerDiscordTyping(surface.state.channelId);
|
|
2724
|
+
} else {
|
|
2725
|
+
closeTypingGate(surface.state.channelId);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
// Durable resume: the continuation token keys the session on the (base or thread) channel id;
|
|
2730
|
+
// a thread is its own channel id so threads resume independently. The reply posts back into
|
|
2731
|
+
// turnState.channelId via the message.completed handler. The framework prepends the
|
|
2732
|
+
// channel's name to the token.
|
|
2733
|
+
// WAKE RENDER โ the ONE shared renderer (normalized-wake.ts renderWake), NOT a Discord-local
|
|
2734
|
+
// dialect. Classify the addressing the native path already computed: a name-drop is `name-drop`
|
|
2735
|
+
// (highest bar); a mention/DM is `direct` (the conversation driver โ raw body pass-through, no
|
|
2736
|
+
// preamble/envelope, byte-identical to the old renderTurn(true)); an engaged non-mention is
|
|
2737
|
+
// `thread-follow` (judged). The judged paths (thread-follow / name-drop) now render the FULL wake
|
|
2738
|
+
// envelope + the canonical Slack-parity preamble instead of the old drifted prose stub โ the
|
|
2739
|
+
// convergence this migration exists to make.
|
|
2740
|
+
const isThread = isThreadChannelType(msg.channelType);
|
|
2741
|
+
const addressing: "direct" | "thread-follow" | "name-drop" =
|
|
2742
|
+
nameDrop && msg.botName ? "name-drop" : msg.mentionsBot || isDm ? "direct" : "thread-follow";
|
|
2743
|
+
|
|
2744
|
+
// PER-WAKE TRUST (identity/wake-trust.ts โ audit-wedge F3), off the SAME auth the session binds
|
|
2745
|
+
// with (the divert path resolves it identically). The trigger's author IS the message sender. A
|
|
2746
|
+
// `direct` body passes through verbatim (trust unused in that render); a judged body renders it as
|
|
2747
|
+
// the envelope's `<message trust=โฆ>` attribute. `principalFallback` on `direct` mirrors the divert
|
|
2748
|
+
// builder's `direct ? "principal" : "peer"` default.
|
|
2749
|
+
const trust = resolveWakeTrustFromContext(
|
|
2750
|
+
ctxFromAuth(surface.auth),
|
|
2751
|
+
{ id: msg.author.id, isBot: msg.author.bot },
|
|
2752
|
+
{ principalFallback: addressing === "direct" },
|
|
2753
|
+
);
|
|
2754
|
+
|
|
2755
|
+
// WAKE/MENTION BACKSCROLL (Slack parity โ discord-backscroll.ts): a name-drop or an @mention used
|
|
2756
|
+
// to reach the model with ONLY the triggering message. Fetch the surrounding conversation as RAW
|
|
2757
|
+
// ContextLines (W3 โ no pre-rendered string flows through the plumbing; renderWake wraps them via
|
|
2758
|
+
// the shared renderContext). We read the SURFACE THE HUMAN IS IN (msg.channelId) โ for an
|
|
2759
|
+
// auto-threaded mention that's the base channel where the conversation is, NOT the fresh empty
|
|
2760
|
+
// reply thread. Skipped for DMs (the 1:1 history IS the conversation, carried by the durable
|
|
2761
|
+
// session) and for plain engaged non-mention wakes (a follow-up in a conversation the session
|
|
2762
|
+
// already holds). Fail-soft: undefined on any error โ wake with just the message, exactly as before.
|
|
2763
|
+
const backscroll =
|
|
2764
|
+
!isDm && (nameDrop || msg.mentionsBot)
|
|
2765
|
+
? await fetchDiscordBackscrollLines({
|
|
2766
|
+
channelId: msg.channelId,
|
|
2767
|
+
triggerMessageId: msg.id,
|
|
2768
|
+
isThread,
|
|
2769
|
+
botId: msg.botId,
|
|
2770
|
+
}).catch(() => undefined)
|
|
2771
|
+
: undefined;
|
|
2772
|
+
// OPE-475: regrade the ContextLine backscroll siblings with the SAME per-line resolution the
|
|
2773
|
+
// trigger uses just above, so a guest hydrated behind a member's trigger is graded too.
|
|
2774
|
+
const gradedBackscrollLines = backscroll ? await gradeDiscordContextLines(backscroll.lines, ctxFromAuth(surface.auth), msg.guildId) : undefined;
|
|
2775
|
+
// THE DOMAIN'S ADMISSION POLICY ON THIS ROAD TOO ({@link admitDiscordSeatLines} โ OPE-1800), and
|
|
2776
|
+
// the seam decides by the ROOM's grain, never by the road. A wake reaches this native arm either
|
|
2777
|
+
// because the room has no seat at all โ then nothing is narrowed โ or because its seat STOOD DOWN
|
|
2778
|
+
// (the guild vouch absent, or no `receive`), which access/window-routing.ts audits as
|
|
2779
|
+
// `native-degraded`. Policing both is what keeps ONE admission answer per domain: a runtime vouch
|
|
2780
|
+
// must not be able to widen what a `members-only` room's wake reads.
|
|
2781
|
+
const nativeContextLines = admitDiscordSeatLines(gradedBackscrollLines, windowGrain, domainKeyOf(partition), isThread);
|
|
2782
|
+
|
|
2783
|
+
const rendered = renderWake(
|
|
2784
|
+
discordNativeWake({
|
|
2785
|
+
channelId: msg.channelId,
|
|
2786
|
+
// Container = the thread's PARENT when known (the domain/spend grain), else the channel; the
|
|
2787
|
+
// conversation is the thread itself. Auth carries the parent when the turn started in a thread
|
|
2788
|
+
// (stamped at the top of dispatchTurn) โ mirrors the divert builder's locus mapping.
|
|
2789
|
+
parentChannelId: str(surface.auth.attributes.parent_channel_id),
|
|
2790
|
+
isThread,
|
|
2791
|
+
isDm,
|
|
2792
|
+
addressing,
|
|
2793
|
+
// The ENROLMENT fact, off the row this dispatch ALREADY read (OPE-1740) โ the same expression
|
|
2794
|
+
// the unified divert passes, so one conversation cannot answer two ways by grain.
|
|
2795
|
+
threadEngaged: engRow?.participating === true && engRow.stopped !== true,
|
|
2796
|
+
botName: msg.botName,
|
|
2797
|
+
contextLines: nativeContextLines,
|
|
2798
|
+
contextSource: backscroll?.source,
|
|
2799
|
+
trigger: {
|
|
2800
|
+
id: msg.id || "unknown",
|
|
2801
|
+
author: msg.author.username ?? msg.author.id,
|
|
2802
|
+
authorId: msg.author.id,
|
|
2803
|
+
sentAt: new Date().toISOString(),
|
|
2804
|
+
text: msg.content,
|
|
2805
|
+
trust,
|
|
2806
|
+
},
|
|
2807
|
+
}),
|
|
2808
|
+
);
|
|
2809
|
+
|
|
2810
|
+
try {
|
|
2811
|
+
await dispatchNativeWakeWithRetry(
|
|
2812
|
+
() =>
|
|
2813
|
+
send(
|
|
2814
|
+
// Backscroll is ADDITIONAL context, never a replacement for the message; omitted when empty so
|
|
2815
|
+
// the payload is identical to today's on the no-history path.
|
|
2816
|
+
rendered.context && rendered.context.length ? { message: rendered.message, context: rendered.context } : rendered.message,
|
|
2817
|
+
{
|
|
2818
|
+
// OPE-466/OPE-475 CARRIAGE, over the TRIGGER *AND* the regraded ContextLine backscroll
|
|
2819
|
+
// siblings. `member.pending` is Discord's only PLATFORM refutation and it is a screening
|
|
2820
|
+
// verdict, and it rides the trigger's own relay payload only โ {@link gradeDiscordContextLines}
|
|
2821
|
+
// documents why it is unreachable for a backscroll sibling (Discord's own message-list REST
|
|
2822
|
+
// shape carries no `member` object). The OPERATOR's refutation (`authorTrust`, OPE-516) IS
|
|
2823
|
+
// reachable per sibling, so it is what closes the gap for a backscroll-hydrated guest; it
|
|
2824
|
+
// yields `{}` on a DM, where there is no guild to arm.
|
|
2825
|
+
auth: stampNonMemberChatAuth(
|
|
2826
|
+
surface.auth,
|
|
2827
|
+
[{ trust, refuted: msg.memberPending === true, ...authorFacts(msg) }, ...(nativeContextLines ?? []).map(contextLineIngestion)],
|
|
2828
|
+
isDm ? { dm: true } : { surfaceKey: domainKeyOf(dispatchPartition) },
|
|
2829
|
+
),
|
|
2830
|
+
continuationToken: surface.continuationToken,
|
|
2831
|
+
state: turnState,
|
|
2832
|
+
},
|
|
2833
|
+
),
|
|
2834
|
+
{ platform: "discord", workspace: dispatchPartition.workspace, surfaceKey: dispatchPartition.surfaceKey },
|
|
2835
|
+
);
|
|
2836
|
+
} catch (err) {
|
|
2837
|
+
// OPE-971: this native guild dispatch (a plain per-channel/per-thread continuation token, NOT
|
|
2838
|
+
// an ambient-supervisor `channel_session` row) has no rotatable registry key โ it is the same
|
|
2839
|
+
// "thread seat, native session" shape stranded-seat.ts already documents as unhealable. This
|
|
2840
|
+
// call turns a completely SILENT failure into an AUDITED one (`session.pruned_deployment_strand`,
|
|
2841
|
+
// remedy "none") without inventing a rotation that cannot exist. No registryKey override: the
|
|
2842
|
+
// token is passed as-is and correctly resolves to `not-a-window`.
|
|
2843
|
+
await healPrunedDeploymentStrand({
|
|
2844
|
+
token: surface.continuationToken,
|
|
2845
|
+
surfaceKey: dispatchPartition.surfaceKey,
|
|
2846
|
+
platform: "discord",
|
|
2847
|
+
atIso: new Date().toISOString(),
|
|
2848
|
+
err,
|
|
2849
|
+
});
|
|
2850
|
+
throw err;
|
|
2851
|
+
}
|
|
2852
|
+
log(
|
|
2853
|
+
`dispatched ${dispatchPartition.surfaceKey} (${isDm ? "dm" : msg.mentionsBot ? "mention" : nameDrop ? "name-drop" : "engaged"}` +
|
|
2854
|
+
`${threadId ? ", auto-threaded+๐" : ""})`,
|
|
2855
|
+
);
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// --- engagement bookkeeping on completion (Slack parity) --------------------
|
|
2859
|
+
// The completion handler runs with only the LIVE session state (no auth), so โ exactly like
|
|
2860
|
+
// slack.ts rebuilds its partition from teamId+channelId in the state โ we rebuild Discord's
|
|
2861
|
+
// engagement partition from the seeded guild + reply channel id. The engagement KEY is the reply
|
|
2862
|
+
// channel id itself (Discord engagement is per-channel; a thread is its own channel id), matching
|
|
2863
|
+
// how dispatchTurn keys it. Returns null for a DM (no guild โ no engagement) or a pre-seed
|
|
2864
|
+
// session โ both then skip engagement bookkeeping, which is exactly right.
|
|
2865
|
+
export function engagementCoordsFor(state: DiscordGatewayState): { p: Partition; key: string } | null {
|
|
2866
|
+
if (!state.guildId || !state.channelId) return null;
|
|
2867
|
+
// userId is irrelevant to the surfaceKey (guild = workspace, channel = surface); pass "" so the
|
|
2868
|
+
// rebuilt partition names discord/<guild>/<channel> exactly as the dispatch partition did.
|
|
2869
|
+
const p = resolvePartition(ctxFromAuth(buildDiscordAuth({ userId: "", guildId: state.guildId, channelId: state.channelId })));
|
|
2870
|
+
return { p, key: state.channelId };
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
// The audit partition for a terminal decision โ same rebuild as engagementCoordsFor but
|
|
2874
|
+
// WITHOUT the guild requirement (a DM turn's silence/reply must still be auditable; its
|
|
2875
|
+
// workspace keys to the binding's DM fallback). Null only when the state names no channel
|
|
2876
|
+
// (pre-seed session) or the rebuild fails โ the caller then skips the row (fail-soft;
|
|
2877
|
+
// auditing must never fail or post into the turn).
|
|
2878
|
+
function auditPartitionForGatewayState(state: DiscordGatewayState): ChatAuditPartition | null {
|
|
2879
|
+
if (!state.channelId) return null;
|
|
2880
|
+
try {
|
|
2881
|
+
return resolvePartition(ctxFromAuth(buildDiscordAuth({ userId: "", guildId: state.guildId, channelId: state.channelId })));
|
|
2882
|
+
} catch {
|
|
2883
|
+
return null;
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
/**
|
|
2888
|
+
* Promote the surface to PARTICIPATING on a POSTED reply (incl. a card-only reply โ the card IS
|
|
2889
|
+
* the reply), mirroring slack.ts's post branch: a warm (name-drop) surface the agent actually
|
|
2890
|
+
* answered goes sticky, its silent streak resets, and its failures go loud.
|
|
2891
|
+
*
|
|
2892
|
+
* THE DISCORD DIVERGENCE (why this reads-then-promotes instead of blindly engaging): Slack promotes
|
|
2893
|
+
* unconditionally because its engagement grain is the THREAD, so a name-drop reply merely
|
|
2894
|
+
* establishing THREAD engagement is harmless. Discord's BASE-channel engagement grain is the WHOLE
|
|
2895
|
+
* CHANNEL, and a base-channel name-drop DELIBERATELY does not engage (dispatchTurn โ else every
|
|
2896
|
+
* subsequent channel message becomes a judged model turn, the "engaged-channel surprise" from live
|
|
2897
|
+
* testing). So a base-channel name-drop reply must NOT create whole-channel engagement. We only
|
|
2898
|
+
* PROMOTE an EXISTING warm record (a thread name-drop engaged at dispatch, or a mention/engaged
|
|
2899
|
+
* surface); no record โ a base-channel name-drop โ leave it un-engaged. Best-effort: engagement
|
|
2900
|
+
* must never fail a delivered reply.
|
|
2901
|
+
*/
|
|
2902
|
+
export async function promoteParticipatingOnReply(state: DiscordGatewayState): Promise<void> {
|
|
2903
|
+
try {
|
|
2904
|
+
const coords = engagementCoordsFor(state);
|
|
2905
|
+
if (!coords) return; // DM / pre-seed session โ nothing to promote
|
|
2906
|
+
const rec = await getThreadEngagement(coords.p, coords.key);
|
|
2907
|
+
if (!rec) return; // no existing engagement (base-channel name-drop) โ don't grab the whole channel
|
|
2908
|
+
// ๐ด `byOwnPost` โ a POSTED reply is not a re-invitation, so this promotion must never lift a
|
|
2909
|
+
// stand-down (OPE-1669, measured on Slack; the shape is identical here). The rule lives in
|
|
2910
|
+
// channels/engagement.ts and re-reads the row rather than reusing `rec` above, so there is
|
|
2911
|
+
// exactly ONE expression of it and no second copy to drift.
|
|
2912
|
+
await markThreadEngaged(coords.p, coords.key, new Date().toISOString(), { participating: true, byOwnPost: true });
|
|
2913
|
+
} catch (err) {
|
|
2914
|
+
console.warn(`[open-everywhere:discord-gateway] participating-promote failed: ${String(err)}`);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
/**
|
|
2919
|
+
* Warm auto-disengage on a JUDGED-SILENT turn (mirrors slack.ts's silent branch): a WARM
|
|
2920
|
+
* (participating:false) surface that keeps judging NO_REPLY is shed after WARM_SILENT_LIMIT turns,
|
|
2921
|
+
* so a one-off name-drop that never earns a reply doesn't leave the bot quietly following forever.
|
|
2922
|
+
* PARTICIPATING surfaces are EXEMPT โ they're sticky, only an explicit stop disengages them โ so a
|
|
2923
|
+
* missing record OR participating:true is a no-op. Best-effort; engagement bookkeeping never throws
|
|
2924
|
+
* out of the completion handler.
|
|
2925
|
+
*/
|
|
2926
|
+
export async function maybeShedWarmSurface(state: DiscordGatewayState): Promise<void> {
|
|
2927
|
+
try {
|
|
2928
|
+
const coords = engagementCoordsFor(state);
|
|
2929
|
+
if (!coords) return;
|
|
2930
|
+
const rec = await getThreadEngagement(coords.p, coords.key);
|
|
2931
|
+
if (rec && rec.participating !== true) {
|
|
2932
|
+
const streak = await bumpThreadSilentStreak(coords.p, coords.key);
|
|
2933
|
+
if (streak >= WARM_SILENT_LIMIT) {
|
|
2934
|
+
await disengageThread(coords.p, coords.key);
|
|
2935
|
+
log(`warm surface shed after ${streak} silent turns โ disengaged (${coords.p.surfaceKey})`);
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
} catch (err) {
|
|
2939
|
+
console.warn(`[open-everywhere:discord-gateway] warm silent-streak bookkeeping failed: ${String(err)}`);
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
export default defineChannel<DiscordGatewayState, DiscordGatewayContext>({
|
|
2944
|
+
kindHint: "discord",
|
|
2945
|
+
// ๐ด QUEUE, NEVER STEER (OPE-412, OPE-1732). eve's default is `steer`, and a steer delivery
|
|
2946
|
+
// carrying a message CANCELS the turn in flight โ rolling session history back to the last
|
|
2947
|
+
// completed model call while that call's tool side effects (posts, PRs, writes) stay real and
|
|
2948
|
+
// unrecorded. The policy resolves per RECEIVING channel, so the pin on `discordChannel`
|
|
2949
|
+
// (channels/discord.ts) covers NOTHING here: that is the slash-command Interactions mount, and
|
|
2950
|
+
// every Discord MESSAGE session lives on this channel, under the `discord:<threadId>` or
|
|
2951
|
+
// `discord:<channelId>` continuation token this route builds. Live-proven on deploy #197
|
|
2952
|
+
// (2026-09-11, session wrun_41M28D8M300GY5HGRQW63MR9ZA): two turns cancelled by ordinary
|
|
2953
|
+
// follow-up messages, one of them 7.3 s after it had already completed and answered. This channel is a raw `defineChannel`,
|
|
2954
|
+
// so `lint:turn-policy` โ which reads the five vendor factories โ cannot see this line;
|
|
2955
|
+
// `scripts/discord-gateway-turn-policy-test.ts` is what holds it. See
|
|
2956
|
+
// packages/providers/slack/agent/channels/slack.ts for the full note.
|
|
2957
|
+
turnPolicy: "queue",
|
|
2958
|
+
// Default seed; every session-starting dispatch overrides it with the real target channel id.
|
|
2959
|
+
state: { channelId: "" },
|
|
2960
|
+
// Hand event handlers the reply surface + the LIVE state object. `state` is eve's mutable
|
|
2961
|
+
// write-back reference (auto-snapshotted at step boundaries) โ turn.started WRITES this turn's
|
|
2962
|
+
// holding-message id into it and message.completed CONSUMES it, which is what keeps the
|
|
2963
|
+
// affordances per-turn-correct on resumed sessions (send-state is first-turn-only).
|
|
2964
|
+
context: (state: DiscordGatewayState): DiscordGatewayContext => ({
|
|
2965
|
+
channelId: state.channelId,
|
|
2966
|
+
state,
|
|
2967
|
+
}),
|
|
2968
|
+
routes: [
|
|
2969
|
+
POST<DiscordGatewayState>("/eve/v1/discord-gateway", async (req, { from, waitUntil, to, attachSession }) => {
|
|
2970
|
+
// eve 0.31 binds the channel-local address at `from(address)` rather than passing it in the
|
|
2971
|
+
// send options, and moved `context` from the payload into the options. {@link
|
|
2972
|
+
// DiscordGatewaySend} is OE's OWN dispatch shape (it long predates 0.31), so the whole
|
|
2973
|
+
// dispatchTurn path below is untouched โ the version-shaped difference is this one adapter.
|
|
2974
|
+
const gatewaySend: DiscordGatewaySend = (message, options) => {
|
|
2975
|
+
const payload = typeof message === "string" ? { message, context: undefined } : message;
|
|
2976
|
+
return from(options.continuationToken).send(payload.message, {
|
|
2977
|
+
auth: options.auth,
|
|
2978
|
+
state: options.state,
|
|
2979
|
+
...(payload.context && payload.context.length > 0 ? { context: payload.context } : {}),
|
|
2980
|
+
} as never);
|
|
2981
|
+
};
|
|
2982
|
+
const rawBody = await req.text();
|
|
2983
|
+
|
|
2984
|
+
// 1) Verify the relay HMAC over the RAW body, fail-closed. Reject BEFORE parsing so we
|
|
2985
|
+
// never act on (or even JSON.parse) an unauthenticated payload.
|
|
2986
|
+
const rejection = relayGate(req.headers.get("x-oe-relay-signature"), rawBody);
|
|
2987
|
+
if (rejection) {
|
|
2988
|
+
log(`rejected (${rejection.status}): ${rejection.reason}`);
|
|
2989
|
+
return new Response(rejection.reason, { status: rejection.status });
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
// 2) Parse defensively. A malformed/unrecognized body is a relay bug, not a normal
|
|
2993
|
+
// no-op โ 400 (a 4xx the relay logs + drops, never retries).
|
|
2994
|
+
let json: unknown;
|
|
2995
|
+
try {
|
|
2996
|
+
json = JSON.parse(rawBody);
|
|
2997
|
+
} catch {
|
|
2998
|
+
return new Response("invalid json", { status: 400 });
|
|
2999
|
+
}
|
|
3000
|
+
// Relay liveness heartbeat (OPE-775) โ classified FIRST, before any wake path (message or
|
|
3001
|
+
// reaction) gets a look at the body. It mints no session, no wake, no reply: one audit row,
|
|
3002
|
+
// written async, ack immediately. See discordHeartbeatAuditRow's doc for what the row proves.
|
|
3003
|
+
const heartbeat = parseRelayHeartbeat(json);
|
|
3004
|
+
if (heartbeat) {
|
|
3005
|
+
waitUntil(appendAudit(discordHeartbeatAuditRow(heartbeat, deploymentOrg())).catch(() => undefined));
|
|
3006
|
+
return new Response("ok", { status: 200 });
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
// Inbound reaction โ a distinct relay payload shape. Handled before the message parse:
|
|
3010
|
+
// ingest async (telemetry always, a durable feedback row on a bot reply), ack immediately.
|
|
3011
|
+
// A reaction never starts a turn โ but since OPE-418 a โ can STOP one, which is why
|
|
3012
|
+
// `attachSession` (eve's I/O-free cancel door, available on every route handler) is handed
|
|
3013
|
+
// in here: the whole point is that a stop costs no session mint and no model call.
|
|
3014
|
+
const reaction = parseRelayReaction(json);
|
|
3015
|
+
if (reaction) {
|
|
3016
|
+
waitUntil(
|
|
3017
|
+
routeDiscordReaction(reaction, liveDiscordReactionDeps(attachSession)).then((o) =>
|
|
3018
|
+
log(`reaction ${reaction.channelId}/${reaction.messageId}: ${o.recorded ? "recorded" : "not recorded"} โ ${o.reason}`),
|
|
3019
|
+
),
|
|
3020
|
+
);
|
|
3021
|
+
return new Response("ok", { status: 200 });
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
const msg = parseRelayPayload(json);
|
|
3025
|
+
if (!msg) return new Response("unrecognized relay payload", { status: 400 });
|
|
3026
|
+
|
|
3027
|
+
// 3) Drop bot authors + empty-content messages as ordinary no-ops (200 โ nothing to
|
|
3028
|
+
// retry). buildDiscordAuth returns null for a bot author.
|
|
3029
|
+
const auth = authForRelayMessage(msg);
|
|
3030
|
+
if (!auth) {
|
|
3031
|
+
// OPE-647: a MISPLACED driver id (pinned to a different guild) is a cross-tenant
|
|
3032
|
+
// admission-refusal worth naming in the audit trail โ every other bot-drop stays silent
|
|
3033
|
+
// (ordinary noise; see isMisplacedDriver's doc for why the two are told apart).
|
|
3034
|
+
if (msg.author.bot && isMisplacedDriver(msg.guildId, msg.author.id)) {
|
|
3035
|
+
await appendAudit({
|
|
3036
|
+
org: deploymentOrg(),
|
|
3037
|
+
platform: "discord",
|
|
3038
|
+
workspace: msg.guildId ?? "unknown",
|
|
3039
|
+
surface: msg.channelId,
|
|
3040
|
+
principalId: msg.author.id,
|
|
3041
|
+
action: "discord.relay_admission_refused",
|
|
3042
|
+
detail: { reason: "e2e-driver credential is pinned to a different guild; this guild is not vouched for it" },
|
|
3043
|
+
}).catch(() => undefined);
|
|
3044
|
+
}
|
|
3045
|
+
return new Response("ignored (bot author)", { status: 200 });
|
|
3046
|
+
}
|
|
3047
|
+
if (msg.content.trim().length === 0) return new Response("ignored (empty content)", { status: 200 });
|
|
3048
|
+
|
|
3049
|
+
// 4) Dispatch async โ the wake gate (engagement lookup) + turn run in the background,
|
|
3050
|
+
// the reply is posted from message.completed. Ack immediately. `to` arms the
|
|
3051
|
+
// single-window divert (byte-identical inert off the crossSurfaceWindow gates); its
|
|
3052
|
+
// fail-closed refusals surface in the catch instead of an unhandled rejection.
|
|
3053
|
+
waitUntil(
|
|
3054
|
+
dispatchTurn(msg, auth, gatewaySend, undefined, undefined, to).catch((err) =>
|
|
3055
|
+
log(`dispatch failed: ${String(err)}`),
|
|
3056
|
+
),
|
|
3057
|
+
);
|
|
3058
|
+
return new Response("ok", { status: 200 });
|
|
3059
|
+
}),
|
|
3060
|
+
],
|
|
3061
|
+
events: {
|
|
3062
|
+
// Working signal: re-trigger the native typing indicator ("<bot> is typingโฆ", ~10s TTL) as
|
|
3063
|
+
// the turn progresses. turn.started covers the turn's opening; each tool-call boundary
|
|
3064
|
+
// (actions.requested / action.result) refreshes it; and the STREAMED text/reasoning chunks
|
|
3065
|
+
// (*.appended, throttled โ they can fire many times a second) keep it alive through the
|
|
3066
|
+
// final answer's generation, so typing runs right up to the reply landing instead of
|
|
3067
|
+
// lapsing ~10s before it. Gaps during a single long silent tool run remain โ same as a
|
|
3068
|
+
// human pausing typing. All best-effort; typing must never delay or drop a turn.
|
|
3069
|
+
//
|
|
3070
|
+
// Every re-trigger ALSO respects the judged-wake typing gate (closeTypingGate at
|
|
3071
|
+
// dispatch): a judged turn shows no typing at all until its streamed message text
|
|
3072
|
+
// deviates from the NO_REPLY sentinel (the message.appended handler feeds the gate) โ
|
|
3073
|
+
// so a judged silence is silent from the very first second.
|
|
3074
|
+
"turn.started": async (_data, channel, ctx) => {
|
|
3075
|
+
if (statusUxEnabled() && typingGateOpen(channel.channelId)) await triggerDiscordTyping(channel.channelId);
|
|
3076
|
+
// OPE-645: REPLY PRINCIPAL snapshot โ snapshotted at turn.started for the
|
|
3077
|
+
// same reason: message.completed has no live auth ctx, so the canonical row it writes cannot
|
|
3078
|
+
// derive WHO from ctx at that point. Since OPE-2006 that row is the DROP row, never a
|
|
3079
|
+
// `reply.post`. authPrincipalId(ctx) is the exact read bindFrom(ctx).principalId falls back to
|
|
3080
|
+
// for an ordinary human turn (identity/binding.ts) โ snapshotting it here means the drop row
|
|
3081
|
+
// names the same person the turn's egress.policy rows already do.
|
|
3082
|
+
(channel.state as unknown as Record<string, unknown>).oeReplyPrincipalId = authPrincipalId(ctx);
|
|
3083
|
+
},
|
|
3084
|
+
// Tool-boundary re-triggers ALSO peek the card signal: action.result fires immediately
|
|
3085
|
+
// after the card-posting tool returns โ an unconditional re-trigger there is exactly the
|
|
3086
|
+
// "typing for ~10s after the card" residue. (Typing can only ever be triggered, never
|
|
3087
|
+
// cancelled โ Discord's indicator is a ~10s TTL โ so going quiet at the right moment is
|
|
3088
|
+
// the ONLY lever; the last legitimate trigger's tail is unavoidable.)
|
|
3089
|
+
"actions.requested": async (_data, channel) => {
|
|
3090
|
+
if (statusUxEnabled() && typingGateOpen(channel.channelId) && !peekCardPostedThisTurn()) await triggerDiscordTyping(channel.channelId);
|
|
3091
|
+
},
|
|
3092
|
+
"action.result": async (_data, channel) => {
|
|
3093
|
+
if (statusUxEnabled() && typingGateOpen(channel.channelId) && !peekCardPostedThisTurn()) await triggerDiscordTyping(channel.channelId);
|
|
3094
|
+
},
|
|
3095
|
+
// The *.appended re-triggers PEEK the card signal (never consume โ message.completed
|
|
3096
|
+
// owns the read-and-reset): once an approval card has posted this turn, the model's
|
|
3097
|
+
// remaining narration is going to be SUPPRESSED, so keeping "typingโฆ" alive for it
|
|
3098
|
+
// would read as "typingโฆ then nothing". The card is the reply; go quiet.
|
|
3099
|
+
"message.appended": async (data, channel) => {
|
|
3100
|
+
if (!statusUxEnabled()) return;
|
|
3101
|
+
const soFar = (data as { messageSoFar?: string } | undefined)?.messageSoFar ?? "";
|
|
3102
|
+
if (!typingGateFeed(channel.channelId, soFar)) return;
|
|
3103
|
+
if (!peekCardPostedThisTurn() && typingRefreshDue(channel.channelId)) await triggerDiscordTyping(channel.channelId);
|
|
3104
|
+
},
|
|
3105
|
+
"reasoning.appended": async (_data, channel) => {
|
|
3106
|
+
if (statusUxEnabled() && typingGateOpen(channel.channelId) && !peekCardPostedThisTurn() && typingRefreshDue(channel.channelId)) await triggerDiscordTyping(channel.channelId);
|
|
3107
|
+
},
|
|
3108
|
+
// A failed turn must NEVER be silent: without this handler a runtime failure left the
|
|
3109
|
+
// human staring at ๐ + a dead thread with no reply and no clue (live 2026-07-06 โ
|
|
3110
|
+
// silence is what made a workflow-side failure invisible). Mirrors eve's own channel
|
|
3111
|
+
// default: post a short honest error to the reply surface. Best-effort, never throws.
|
|
3112
|
+
"turn.failed": async (data, channel) => {
|
|
3113
|
+
openTypingGate(channel.channelId); // turn over โ never leak a closed gate into the next
|
|
3114
|
+
const detail = (data as { error?: { message?: string } } | undefined)?.error?.message;
|
|
3115
|
+
log(`turn failed (channel ${channel.channelId || "?"})${detail ? `: ${detail}` : ""}`);
|
|
3116
|
+
await postDiscordReply(
|
|
3117
|
+
channel.channelId,
|
|
3118
|
+
"โ ๏ธ I hit an error while handling that and couldn't finish. Please try again โ and if it keeps failing, check the deployment logs.",
|
|
3119
|
+
);
|
|
3120
|
+
},
|
|
3121
|
+
// Deliver the turn's final reply back to Discord AND reconcile the ๐โโ
swap (auto-threaded
|
|
3122
|
+
// mentions only). finalMessageAction (the shared, unit-tested decision slack-events.ts owns)
|
|
3123
|
+
// stays the SOURCE OF TRUTH for whether there's a reply โ a watch routine's NO_REPLY sentinel
|
|
3124
|
+
// is SWALLOWED, and tool-only / empty finals post nothing โ exactly as
|
|
3125
|
+
// channels/discord.ts does. planCompletion turns that decision (+ whether this turn seeded swap
|
|
3126
|
+
// coords, read consume-once from the live state) into the concrete ops; the handler is a thin
|
|
3127
|
+
// executor. The reply is always a FRESH post โ never an edit, so no "(edited)" tag. Every
|
|
3128
|
+
// affordance op is best-effort/fail-open: the reply lands even if a reaction call fails.
|
|
3129
|
+
"message.completed": async (data, channel, ctx) => {
|
|
3130
|
+
const sessionId = ctx?.session?.id; // stamped into the terminal audit rows โ joins to the transcript
|
|
3131
|
+
const rawAction = finalMessageAction(data.finishReason, data.message);
|
|
3132
|
+
// An interim tool-call step is NOT the final reply โ touch nothing (typing re-triggers
|
|
3133
|
+
// via the action events above; the swap coords stay for the real completion).
|
|
3134
|
+
if (rawAction.kind === "buffer-tool-status") return;
|
|
3135
|
+
// Turn is over โ clear the judged-wake typing gate so it can't leak into the next turn
|
|
3136
|
+
// on this channel (a silent judged turn leaves its gate closed by design).
|
|
3137
|
+
openTypingGate(channel.channelId);
|
|
3138
|
+
// Consume-once: read this turn's swap coords and CLEAR them, so a resumed later turn can
|
|
3139
|
+
// never react on THIS turn's message (the staleness class an earlier design hit).
|
|
3140
|
+
const status = takeTurnStatus(channel.state);
|
|
3141
|
+
// Card-signal (approvals/card-signal.ts, read-and-reset): a propose tool posted an
|
|
3142
|
+
// approval card this turn โ the card IS the reply; suppress the model's narration
|
|
3143
|
+
// exactly as channels/slack.ts does. Consumed only on FINAL actions (the buffer-tool
|
|
3144
|
+
// branch returned above), so an interim completion can't eat the signal early.
|
|
3145
|
+
// repliedThisTurn (reply-signal.ts, same read-and-reset contract as channels/
|
|
3146
|
+
// discord.ts): a mid-turn reply-tool post means an empty terminal is an ANSWERED
|
|
3147
|
+
// turn, not a silence โ consumed here so it never bleeds into the next turn either.
|
|
3148
|
+
// TURN-ID KEYED (OPE-805): only THIS turn's own mark answers true.
|
|
3149
|
+
// ๐ด READ ONCE (OPE-1639). The turn id keys every per-turn signal this handler consumes. Since
|
|
3150
|
+
// OPE-2006 there is no terminal reply audit below to key as well: the closing prose is dropped
|
|
3151
|
+
// and audited, so one read serves the silence decision and the signal consumes alike.
|
|
3152
|
+
const turnId = (ctx as unknown as { session?: { turn?: { id?: string } } })?.session?.turn?.id;
|
|
3153
|
+
const repliedThisTurn = consumeRepliedThisTurn(turnId);
|
|
3154
|
+
const cardPostedThisTurn = consumeCardPostedThisTurn();
|
|
3155
|
+
// OPE-890: read-and-reset the no_reply TOOL signal (no-reply-signal.ts), same contract as
|
|
3156
|
+
// Slack/Teams' finalMessageActionForTurn โ needed here ONLY to tell the two silence routes
|
|
3157
|
+
// apart below. Discord's OWN silence decision never needed this (isNoReply(message) alone
|
|
3158
|
+
// decides it, because the tool's own note instructs the model to also emit the sentinel
|
|
3159
|
+
// text โ see tools/no_reply.ts's END_TURN_WITH_SENTINEL_INSTRUCTION), so consuming it here
|
|
3160
|
+
// changes nothing about what gets posted or suppressed.
|
|
3161
|
+
const noReplyToolCalledThisTurn = consumeNoReplyThisTurn() !== null;
|
|
3162
|
+
// OPE-1647 โ THE STAND-DOWN LATCH, and this handler needs its OWN copy of the read. Slack,
|
|
3163
|
+
// Teams and the Discord INTERACTIONS channel take it inside `finalMessageActionForTurn`; this
|
|
3164
|
+
// one calls the pure `finalMessageAction` instead, because it consumes the `no_reply` signal
|
|
3165
|
+
// itself on the line above to tell the two silence routes apart. Swapping in the ForTurn
|
|
3166
|
+
// variant would consume that signal first and break the split, so the latch is read here, on
|
|
3167
|
+
// the same read-and-reset contract, and turned into the silent verdict below.
|
|
3168
|
+
// ๐ด THIS IS THE GUILD PATH. Every guild wake completes here rather than in channels/
|
|
3169
|
+
// discord.ts, so without this read a stood-down Discord thread reproduces deploy #187 exactly:
|
|
3170
|
+
// the acknowledgement posts, the latch is set, nothing reads it, and the trailing prose follows
|
|
3171
|
+
// it into the conversation the person asked the agent to leave.
|
|
3172
|
+
const stoodDownSilentThisTurn = consumeStandDownSilenceThisTurn(turnId);
|
|
3173
|
+
const action: FinalMessageAction =
|
|
3174
|
+
stoodDownSilentThisTurn && rawAction.kind === "post" ? { kind: "silent", reason: STAND_DOWN_SILENCE_REASON } : rawAction;
|
|
3175
|
+
const plan = planCompletion({
|
|
3176
|
+
action,
|
|
3177
|
+
reactionActive: Boolean(status.original),
|
|
3178
|
+
cardPostedThisTurn,
|
|
3179
|
+
});
|
|
3180
|
+
|
|
3181
|
+
// 1) Reaction โ remove the ๐ from the ORIGINAL mention in its base channel (not the
|
|
3182
|
+
// reply surface, which is the thread). Absence = done; the bot never plants a โ
|
|
3183
|
+
// (reserved for humans โ the Slack-established convention).
|
|
3184
|
+
if (plan.reaction === "clear" && status.original) {
|
|
3185
|
+
await setDiscordReaction(status.original.channelId, status.original.messageId, EYES_EMOJI, "DELETE");
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
// 2) Deliver the reply into the reply surface (channel.channelId) as a fresh post.
|
|
3189
|
+
// AUDIT the posted reply (issue-surface parity): a discord.replied row records what was
|
|
3190
|
+
// said. ACTIONS-ONLY posture (audit-wedge build plan ยง1c, 2026-07-14): a judged silence
|
|
3191
|
+
// is NOT an audited action โ the transcript answers "why did it stay quiet?", not the
|
|
3192
|
+
// audit log. The partition is rebuilt from the live state; best-effort, never fails the turn.
|
|
3193
|
+
const auditP = channel.state.channelId
|
|
3194
|
+
? auditPartitionForGatewayState(channel.state)
|
|
3195
|
+
: null;
|
|
3196
|
+
if (plan.delivery.kind === "post") {
|
|
3197
|
+
const text = action.kind === "post" ? action.text : plan.delivery.chunks.join("\n");
|
|
3198
|
+
// ๐ด THE CLOSING PROSE IS DROPPED, NOT POSTED (OPE-2006). This branch used to route the
|
|
3199
|
+
// guild path's terminal reply through `postTerminalProse`, with a raw `postDiscordReply`
|
|
3200
|
+
// fallback for an unidentifiable state. Both are gone: the `reply` tool is the only channel a
|
|
3201
|
+
// seat's words leave by, so a turn that ended in prose and never called `reply` has said
|
|
3202
|
+
// nothing here. The row is the record โ no re-send, no queue, no host-side re-post.
|
|
3203
|
+
// WHAT TELLS THE MODEL is the seat prompt: Discord declares `closingProseDropped`, so a
|
|
3204
|
+
// conversational seat here reads the `reply-only` delivery arm, whose copy states that the
|
|
3205
|
+
// turn-final text is discarded โ inside its persona when the token is supervisor-shaped, and
|
|
3206
|
+
// through the `delivery` slot when it is not (instructions/access.ts `renderDeliverySlot`).
|
|
3207
|
+
if (auditP) {
|
|
3208
|
+
await dropClosingProse({
|
|
3209
|
+
row: {
|
|
3210
|
+
org: auditP.org,
|
|
3211
|
+
platform: auditP.platform,
|
|
3212
|
+
workspace: auditP.workspace,
|
|
3213
|
+
surface: auditP.surface,
|
|
3214
|
+
principalId: (channel.state as unknown as Record<string, unknown>).oeReplyPrincipalId as string | undefined,
|
|
3215
|
+
},
|
|
3216
|
+
chars: text.length,
|
|
3217
|
+
...(sessionId ? { sessionId } : {}),
|
|
3218
|
+
anchor: { channelId: channel.channelId || null },
|
|
3219
|
+
log: (m) => log(`${m} (channel ${channel.channelId || "?"})`),
|
|
3220
|
+
});
|
|
3221
|
+
} else {
|
|
3222
|
+
// No resolvable partition, so there is no row to file it under โ the drop still happens and
|
|
3223
|
+
// the log line is the whole record.
|
|
3224
|
+
log(`closing prose DROPPED (${text.length}c) โ nothing posted, and no partition to audit it under (channel ${channel.channelId || "?"})`);
|
|
3225
|
+
}
|
|
3226
|
+
} else if (action.kind === "silent") {
|
|
3227
|
+
if (data.message) log(`judged silence (NO_REPLY) โ nothing posted (channel ${channel.channelId || "?"})`);
|
|
3228
|
+
// OPE-890: the sentinel route's own ALWAYS-ON row โ the twin of tools/no_reply.ts's
|
|
3229
|
+
// tool-route `reply.no_reply_observed` row (OPE-649). Only for a GENUINE bare-sentinel
|
|
3230
|
+
// turn (the tool was not called this turn) โ a tool-then-sentinel turn already wrote its
|
|
3231
|
+
// own row inside the tool's execute() and must not be double-counted here.
|
|
3232
|
+
if (action.reason === NO_REPLY_SENTINEL_REASON && !noReplyToolCalledThisTurn && auditP) {
|
|
3233
|
+
await auditNoReplySentinelObserved(auditP, sessionId).catch(() => undefined);
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
// 3) ENGAGEMENT BOOKKEEPING (Slack parity). A DELIVERED reply PROMOTES the surface to
|
|
3238
|
+
// participating (sticky, streak reset). A JUDGED-SILENT turn (NO_REPLY / empty final)
|
|
3239
|
+
// bumps the warm silent streak and sheds the surface at the limit. Both best-effort and
|
|
3240
|
+
// gated on the seeded guild โ a DM / pre-seed session is a no-op.
|
|
3241
|
+
// ๐ด THE PREDICATE IS THE DELIVERY, NOT `action.kind` (OPE-2006). It read `action.kind ===
|
|
3242
|
+
// "post"` while that meant "the closing prose posted". The prose is DROPPED now, so a turn
|
|
3243
|
+
// that ended in text and never called `reply` put NOTHING in the room โ and promoting on it
|
|
3244
|
+
// would make a warm name-drop surface sticky-participating on an answer nobody saw, while
|
|
3245
|
+
// the `silent` branch below never runs to shed it. OPE-771 measured that shape at 23 of 36
|
|
3246
|
+
// owed answers. Only a `reply` tool post or an approval card is bytes in the room, which is
|
|
3247
|
+
// the same pair `channels/slack.ts` promotes on.
|
|
3248
|
+
const deliveredThisTurn = repliedThisTurn || cardPostedThisTurn;
|
|
3249
|
+
if (deliveredThisTurn) {
|
|
3250
|
+
await promoteParticipatingOnReply(channel.state);
|
|
3251
|
+
} else if (action.kind === "silent") {
|
|
3252
|
+
await maybeShedWarmSurface(channel.state);
|
|
3253
|
+
}
|
|
3254
|
+
},
|
|
3255
|
+
},
|
|
3256
|
+
});
|