@open-mercato/channel-discord 0.6.8-develop.7090.1.4dbca7b350
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/.turbo/turbo-build.log +2 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js +86 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js +39 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js +97 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js +65 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js +110 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js +103 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js +93 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js +52 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js +46 -0
- package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js +16 -0
- package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/meta.js +10 -0
- package/dist/modules/channel_discord/__integration__/meta.js.map +7 -0
- package/dist/modules/channel_discord/acl.js +15 -0
- package/dist/modules/channel_discord/acl.js.map +7 -0
- package/dist/modules/channel_discord/ai-agents.js +97 -0
- package/dist/modules/channel_discord/ai-agents.js.map +7 -0
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js +73 -0
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js.map +7 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js +222 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js.map +7 -0
- package/dist/modules/channel_discord/api/interactions/route.js +173 -0
- package/dist/modules/channel_discord/api/interactions/route.js.map +7 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js +196 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js.map +7 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js +14 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js.map +7 -0
- package/dist/modules/channel_discord/cli.js +142 -0
- package/dist/modules/channel_discord/cli.js.map +7 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js +116 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js.map +7 -0
- package/dist/modules/channel_discord/commands/update-ai-auto-reply.js +104 -0
- package/dist/modules/channel_discord/commands/update-ai-auto-reply.js.map +7 -0
- package/dist/modules/channel_discord/di.js +23 -0
- package/dist/modules/channel_discord/di.js.map +7 -0
- package/dist/modules/channel_discord/index.js +9 -0
- package/dist/modules/channel_discord/index.js.map +7 -0
- package/dist/modules/channel_discord/integration.js +96 -0
- package/dist/modules/channel_discord/integration.js.map +7 -0
- package/dist/modules/channel_discord/lib/adapter.js +248 -0
- package/dist/modules/channel_discord/lib/adapter.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-agent-directory.js +45 -0
- package/dist/modules/channel_discord/lib/ai-agent-directory.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-features.js +9 -0
- package/dist/modules/channel_discord/lib/ai-features.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-proposal-contract.js +9 -0
- package/dist/modules/channel_discord/lib/ai-proposal-contract.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-proposal.js +109 -0
- package/dist/modules/channel_discord/lib/ai-proposal.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-reply.js +96 -0
- package/dist/modules/channel_discord/lib/ai-reply.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-service-principal.js +70 -0
- package/dist/modules/channel_discord/lib/ai-service-principal.js.map +7 -0
- package/dist/modules/channel_discord/lib/capabilities.js +42 -0
- package/dist/modules/channel_discord/lib/capabilities.js.map +7 -0
- package/dist/modules/channel_discord/lib/channel-access.js +62 -0
- package/dist/modules/channel_discord/lib/channel-access.js.map +7 -0
- package/dist/modules/channel_discord/lib/channel-state-store.js +103 -0
- package/dist/modules/channel_discord/lib/channel-state-store.js.map +7 -0
- package/dist/modules/channel_discord/lib/convert-outbound.js +38 -0
- package/dist/modules/channel_discord/lib/convert-outbound.js.map +7 -0
- package/dist/modules/channel_discord/lib/credentials.js +61 -0
- package/dist/modules/channel_discord/lib/credentials.js.map +7 -0
- package/dist/modules/channel_discord/lib/discord-gateway-client.js +289 -0
- package/dist/modules/channel_discord/lib/discord-gateway-client.js.map +7 -0
- package/dist/modules/channel_discord/lib/discord-rest.js +203 -0
- package/dist/modules/channel_discord/lib/discord-rest.js.map +7 -0
- package/dist/modules/channel_discord/lib/failure-reason.js +30 -0
- package/dist/modules/channel_discord/lib/failure-reason.js.map +7 -0
- package/dist/modules/channel_discord/lib/gateway-bridge.js +42 -0
- package/dist/modules/channel_discord/lib/gateway-bridge.js.map +7 -0
- package/dist/modules/channel_discord/lib/health.js +37 -0
- package/dist/modules/channel_discord/lib/health.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-dispatch.js +164 -0
- package/dist/modules/channel_discord/lib/interactions-dispatch.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-handler.js +115 -0
- package/dist/modules/channel_discord/lib/interactions-handler.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-queue.js +17 -0
- package/dist/modules/channel_discord/lib/interactions-queue.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-verify.js +84 -0
- package/dist/modules/channel_discord/lib/interactions-verify.js.map +7 -0
- package/dist/modules/channel_discord/lib/normalize-inbound.js +59 -0
- package/dist/modules/channel_discord/lib/normalize-inbound.js.map +7 -0
- package/dist/modules/channel_discord/lib/preset.js +63 -0
- package/dist/modules/channel_discord/lib/preset.js.map +7 -0
- package/dist/modules/channel_discord/lib/queue-strategy.js +12 -0
- package/dist/modules/channel_discord/lib/queue-strategy.js.map +7 -0
- package/dist/modules/channel_discord/lib/slash-commands.js +42 -0
- package/dist/modules/channel_discord/lib/slash-commands.js.map +7 -0
- package/dist/modules/channel_discord/message-types.js +64 -0
- package/dist/modules/channel_discord/message-types.js.map +7 -0
- package/dist/modules/channel_discord/setup.js +62 -0
- package/dist/modules/channel_discord/setup.js.map +7 -0
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js +185 -0
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js +79 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js +20 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js +220 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection-table.js +26 -0
- package/dist/modules/channel_discord/widgets/injection-table.js.map +7 -0
- package/dist/modules/channel_discord/workers/discord-gateway.js +270 -0
- package/dist/modules/channel_discord/workers/discord-gateway.js.map +7 -0
- package/dist/modules/channel_discord/workers/discord-interactions.js +113 -0
- package/dist/modules/channel_discord/workers/discord-interactions.js.map +7 -0
- package/jest.config.cjs +38 -0
- package/package.json +104 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.ts +109 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.ts +58 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.ts +175 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.ts +83 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.ts +156 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.ts +144 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.ts +119 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.ts +75 -0
- package/src/modules/channel_discord/__integration__/helpers/discordSignature.ts +77 -0
- package/src/modules/channel_discord/__integration__/helpers/eventsQueue.ts +26 -0
- package/src/modules/channel_discord/__integration__/meta.ts +6 -0
- package/src/modules/channel_discord/__tests__/ai-auto-reply.policy.integration.test.ts +286 -0
- package/src/modules/channel_discord/acl.ts +19 -0
- package/src/modules/channel_discord/ai-agents.ts +157 -0
- package/src/modules/channel_discord/api/ai-auto-reply/channels/__tests__/route.test.ts +175 -0
- package/src/modules/channel_discord/api/ai-auto-reply/channels/route.ts +99 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/__tests__/route.test.ts +311 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.ts +299 -0
- package/src/modules/channel_discord/api/interactions/route.ts +242 -0
- package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.ts +18 -0
- package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.tsx +288 -0
- package/src/modules/channel_discord/cli.ts +234 -0
- package/src/modules/channel_discord/commands/__tests__/ai-reply-proposal.test.ts +155 -0
- package/src/modules/channel_discord/commands/__tests__/update-ai-auto-reply.test.ts +185 -0
- package/src/modules/channel_discord/commands/ai-reply-proposal.ts +162 -0
- package/src/modules/channel_discord/commands/update-ai-auto-reply.ts +157 -0
- package/src/modules/channel_discord/di.ts +26 -0
- package/src/modules/channel_discord/i18n/de.json +57 -0
- package/src/modules/channel_discord/i18n/en.json +57 -0
- package/src/modules/channel_discord/i18n/es.json +57 -0
- package/src/modules/channel_discord/i18n/ko.json +57 -0
- package/src/modules/channel_discord/i18n/pl.json +57 -0
- package/src/modules/channel_discord/index.ts +6 -0
- package/src/modules/channel_discord/integration.ts +98 -0
- package/src/modules/channel_discord/lib/__tests__/adapter.test.ts +206 -0
- package/src/modules/channel_discord/lib/__tests__/ai-agent-directory.test.ts +61 -0
- package/src/modules/channel_discord/lib/__tests__/ai-reply.test.ts +115 -0
- package/src/modules/channel_discord/lib/__tests__/capabilities.test.ts +170 -0
- package/src/modules/channel_discord/lib/__tests__/channel-quarantine.test.ts +89 -0
- package/src/modules/channel_discord/lib/__tests__/channel-state-store.auto-reply-marker.test.ts +145 -0
- package/src/modules/channel_discord/lib/__tests__/channel-state-store.test.ts +78 -0
- package/src/modules/channel_discord/lib/__tests__/convert-outbound.test.ts +39 -0
- package/src/modules/channel_discord/lib/__tests__/discord-gateway-client.test.ts +243 -0
- package/src/modules/channel_discord/lib/__tests__/failure-reason.test.ts +68 -0
- package/src/modules/channel_discord/lib/__tests__/gateway-bridge.test.ts +62 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-dispatch.test.ts +223 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-handler.test.ts +469 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-verify.test.ts +126 -0
- package/src/modules/channel_discord/lib/__tests__/normalize-inbound.identity.test.ts +64 -0
- package/src/modules/channel_discord/lib/__tests__/normalize-inbound.test.ts +67 -0
- package/src/modules/channel_discord/lib/__tests__/preset.test.ts +110 -0
- package/src/modules/channel_discord/lib/__tests__/queue-strategy.test.ts +33 -0
- package/src/modules/channel_discord/lib/adapter.ts +359 -0
- package/src/modules/channel_discord/lib/ai-agent-directory.ts +124 -0
- package/src/modules/channel_discord/lib/ai-features.ts +23 -0
- package/src/modules/channel_discord/lib/ai-proposal-contract.ts +24 -0
- package/src/modules/channel_discord/lib/ai-proposal.ts +179 -0
- package/src/modules/channel_discord/lib/ai-reply.ts +179 -0
- package/src/modules/channel_discord/lib/ai-service-principal.ts +143 -0
- package/src/modules/channel_discord/lib/capabilities.ts +104 -0
- package/src/modules/channel_discord/lib/channel-access.ts +110 -0
- package/src/modules/channel_discord/lib/channel-state-store.ts +217 -0
- package/src/modules/channel_discord/lib/convert-outbound.ts +88 -0
- package/src/modules/channel_discord/lib/credentials.ts +88 -0
- package/src/modules/channel_discord/lib/discord-gateway-client.ts +432 -0
- package/src/modules/channel_discord/lib/discord-rest.ts +393 -0
- package/src/modules/channel_discord/lib/failure-reason.ts +49 -0
- package/src/modules/channel_discord/lib/gateway-bridge.ts +79 -0
- package/src/modules/channel_discord/lib/health.ts +55 -0
- package/src/modules/channel_discord/lib/interactions-dispatch.ts +288 -0
- package/src/modules/channel_discord/lib/interactions-handler.ts +267 -0
- package/src/modules/channel_discord/lib/interactions-queue.ts +52 -0
- package/src/modules/channel_discord/lib/interactions-verify.ts +174 -0
- package/src/modules/channel_discord/lib/normalize-inbound.ts +89 -0
- package/src/modules/channel_discord/lib/preset.ts +109 -0
- package/src/modules/channel_discord/lib/queue-strategy.ts +29 -0
- package/src/modules/channel_discord/lib/slash-commands.ts +57 -0
- package/src/modules/channel_discord/message-types.ts +75 -0
- package/src/modules/channel_discord/setup.ts +78 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.armed.test.ts +149 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.failure-marker.test.ts +133 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.test.ts +273 -0
- package/src/modules/channel_discord/subscribers/ai-auto-reply.ts +325 -0
- package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.tsx +143 -0
- package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.ts +19 -0
- package/src/modules/channel_discord/widgets/injection/connect/widget.client.tsx +237 -0
- package/src/modules/channel_discord/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_discord/widgets/injection-table.ts +24 -0
- package/src/modules/channel_discord/workers/__tests__/discord-gateway.single-identify.test.ts +88 -0
- package/src/modules/channel_discord/workers/__tests__/discord-gateway.test.ts +179 -0
- package/src/modules/channel_discord/workers/__tests__/discord-interactions.test.ts +152 -0
- package/src/modules/channel_discord/workers/discord-gateway.ts +458 -0
- package/src/modules/channel_discord/workers/discord-interactions.ts +162 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +7 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import type { DiscordMessageObject } from './discord-rest'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Discord Gateway (WebSocket) client. Uses the Node >= 22 built-in global
|
|
5
|
+
* `WebSocket` — no `ws` dependency. The network-touching class only opens a
|
|
6
|
+
* socket when `connect()` is called, so importing this module has no side
|
|
7
|
+
* effects; the state-machine helpers below are pure and unit-tested.
|
|
8
|
+
*
|
|
9
|
+
* Swappable in tests / alternate transports via `setDiscordGatewayClient(...)`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ── Gateway opcodes ───────────────────────────────────────────
|
|
13
|
+
export const GatewayOpcode = {
|
|
14
|
+
DISPATCH: 0,
|
|
15
|
+
HEARTBEAT: 1,
|
|
16
|
+
IDENTIFY: 2,
|
|
17
|
+
RESUME: 6,
|
|
18
|
+
RECONNECT: 7,
|
|
19
|
+
INVALID_SESSION: 9,
|
|
20
|
+
HELLO: 10,
|
|
21
|
+
HEARTBEAT_ACK: 11,
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
// ── Gateway intents (bitfield) ────────────────────────────────
|
|
25
|
+
const INTENT_GUILDS = 1 << 0
|
|
26
|
+
const INTENT_GUILD_MESSAGES = 1 << 9
|
|
27
|
+
const INTENT_GUILD_MESSAGE_REACTIONS = 1 << 10
|
|
28
|
+
const INTENT_MESSAGE_CONTENT = 1 << 15
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Default intents the bot identifies with: guild lifecycle, guild message
|
|
32
|
+
* create/reactions, and (privileged) message content so inbound text is
|
|
33
|
+
* readable. `DIRECT_MESSAGES` is intentionally omitted for the first release.
|
|
34
|
+
*/
|
|
35
|
+
export const DISCORD_GATEWAY_INTENTS =
|
|
36
|
+
INTENT_GUILDS | INTENT_GUILD_MESSAGES | INTENT_GUILD_MESSAGE_REACTIONS | INTENT_MESSAGE_CONTENT
|
|
37
|
+
|
|
38
|
+
export const DISCORD_GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fatal close codes: the bot token or intents are wrong and reconnecting will
|
|
42
|
+
* not help. The worker surfaces these as `requires_reauth` instead of looping.
|
|
43
|
+
* 4004 Authentication failed, 4014 Disallowed intents.
|
|
44
|
+
*/
|
|
45
|
+
const FATAL_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014])
|
|
46
|
+
|
|
47
|
+
export function isFatalGatewayCloseCode(code: number | undefined): boolean {
|
|
48
|
+
return typeof code === 'number' && FATAL_CLOSE_CODES.has(code)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether a close code invalidates the current session so the next connect must
|
|
53
|
+
* re-`IDENTIFY` (fresh session) rather than `RESUME`. 4007 (invalid seq) and
|
|
54
|
+
* 4009 (session timed out) require a fresh identify.
|
|
55
|
+
*/
|
|
56
|
+
export function shouldResumeAfterClose(code: number | undefined): boolean {
|
|
57
|
+
if (code === undefined) return true
|
|
58
|
+
if (isFatalGatewayCloseCode(code)) return false
|
|
59
|
+
return code !== 4007 && code !== 4009 && code !== 1000 && code !== 1001
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Heartbeat/ACK zombie detector. Discord expects a Heartbeat ACK (opcode 11)
|
|
64
|
+
* after every heartbeat we send; a missing ACK before the next heartbeat means
|
|
65
|
+
* the connection is a zombie (TCP up, gateway dead) and MUST be torn down +
|
|
66
|
+
* reconnected rather than left silently deaf. Pure + unit-testable.
|
|
67
|
+
*
|
|
68
|
+
* Contract: call `onBeat()` on each heartbeat-interval tick — it returns
|
|
69
|
+
* `'reconnect'` when the previous beat was never acked, else `'send'` (and marks
|
|
70
|
+
* the new beat pending). Call `onAck()` on opcode 11. `reset()` re-arms after a
|
|
71
|
+
* fresh (re)connect.
|
|
72
|
+
*/
|
|
73
|
+
export function createHeartbeatMonitor() {
|
|
74
|
+
let acked = true
|
|
75
|
+
return {
|
|
76
|
+
onBeat(): 'send' | 'reconnect' {
|
|
77
|
+
if (!acked) return 'reconnect'
|
|
78
|
+
acked = false
|
|
79
|
+
return 'send'
|
|
80
|
+
},
|
|
81
|
+
onAck(): void {
|
|
82
|
+
acked = true
|
|
83
|
+
},
|
|
84
|
+
reset(): void {
|
|
85
|
+
acked = true
|
|
86
|
+
},
|
|
87
|
+
isAcked(): boolean {
|
|
88
|
+
return acked
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type HeartbeatMonitor = ReturnType<typeof createHeartbeatMonitor>
|
|
94
|
+
|
|
95
|
+
/** Exponential backoff with jitter, capped, for reconnect attempts. */
|
|
96
|
+
export function computeReconnectDelayMs(attempt: number): number {
|
|
97
|
+
const base = 1000 * Math.pow(2, Math.min(attempt, 5))
|
|
98
|
+
const jitter = Math.floor(Math.random() * 500)
|
|
99
|
+
return Math.min(base + jitter, 30_000)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface IdentifyPayload {
|
|
103
|
+
op: number
|
|
104
|
+
d: {
|
|
105
|
+
token: string
|
|
106
|
+
intents: number
|
|
107
|
+
properties: { os: string; browser: string; device: string }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildIdentifyPayload(botToken: string, intents: number = DISCORD_GATEWAY_INTENTS): IdentifyPayload {
|
|
112
|
+
return {
|
|
113
|
+
op: GatewayOpcode.IDENTIFY,
|
|
114
|
+
d: {
|
|
115
|
+
token: botToken,
|
|
116
|
+
intents,
|
|
117
|
+
properties: { os: 'linux', browser: 'open-mercato', device: 'open-mercato' },
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ResumePayload {
|
|
123
|
+
op: number
|
|
124
|
+
d: { token: string; session_id: string; seq: number | null }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function buildResumePayload(botToken: string, sessionId: string, seq: number | null): ResumePayload {
|
|
128
|
+
return {
|
|
129
|
+
op: GatewayOpcode.RESUME,
|
|
130
|
+
d: { token: botToken, session_id: sessionId, seq },
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function buildHeartbeatPayload(seq: number | null): { op: number; d: number | null } {
|
|
135
|
+
return { op: GatewayOpcode.HEARTBEAT, d: seq }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Connection interface ──────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
export interface GatewayResumeState {
|
|
141
|
+
sessionId?: string
|
|
142
|
+
sequence?: number | null
|
|
143
|
+
resumeGatewayUrl?: string
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface DiscordGatewayConnectOptions {
|
|
147
|
+
botToken: string
|
|
148
|
+
intents?: number
|
|
149
|
+
resumeState?: GatewayResumeState
|
|
150
|
+
/** Called for each `MESSAGE_CREATE` dispatch. */
|
|
151
|
+
onMessage: (message: DiscordMessageObject) => void | Promise<void>
|
|
152
|
+
/** Called for each `MESSAGE_REACTION_ADD` / `_REMOVE` dispatch. */
|
|
153
|
+
onReaction: (reaction: Record<string, unknown>, action: 'added' | 'removed') => void | Promise<void>
|
|
154
|
+
/** Called on READY with the bot's own user id + fresh resume state. */
|
|
155
|
+
onReady: (info: { botUserId: string; resumeState: GatewayResumeState }) => void | Promise<void>
|
|
156
|
+
/** Called on a fatal auth/intents close code (4004/4014). */
|
|
157
|
+
onRequiresReauth: (info: { code: number }) => void | Promise<void>
|
|
158
|
+
/** Optional persisted resume-state updates (session id + sequence). */
|
|
159
|
+
onResumeStateChange?: (resumeState: GatewayResumeState) => void
|
|
160
|
+
/** Injected WebSocket factory for tests; defaults to global `WebSocket`. */
|
|
161
|
+
webSocketFactory?: (url: string) => GatewayWebSocketLike
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface GatewayWebSocketLike {
|
|
165
|
+
send(data: string): void
|
|
166
|
+
close(code?: number, reason?: string): void
|
|
167
|
+
addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: unknown) => void): void
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface DiscordGatewayHandle {
|
|
171
|
+
/** Close the socket and stop reconnecting. */
|
|
172
|
+
close(): void
|
|
173
|
+
/**
|
|
174
|
+
* Whether this handle still owns a running session. A session that is merely
|
|
175
|
+
* between sockets (reconnect backoff after a resumable close) is still active —
|
|
176
|
+
* it heals itself. It reports inactive only after `close()` or a fatal close
|
|
177
|
+
* code, which is exactly when the gateway worker must open a new one.
|
|
178
|
+
*/
|
|
179
|
+
isActive(): boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface DiscordGatewayClient {
|
|
183
|
+
connect(options: DiscordGatewayConnectOptions): DiscordGatewayHandle
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Native-WebSocket-backed gateway connection. Handles the HELLO→IDENTIFY/RESUME
|
|
188
|
+
* handshake, heartbeat with sequence tracking, dispatch fan-out, and
|
|
189
|
+
* reconnect-with-backoff. One instance owns exactly one socket.
|
|
190
|
+
*/
|
|
191
|
+
class NativeDiscordGatewayClient implements DiscordGatewayClient {
|
|
192
|
+
connect(options: DiscordGatewayConnectOptions): DiscordGatewayHandle {
|
|
193
|
+
const connection = new GatewaySession(options)
|
|
194
|
+
connection.open()
|
|
195
|
+
return {
|
|
196
|
+
close: () => connection.stop(),
|
|
197
|
+
isActive: () => connection.isRunning(),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
class GatewaySession {
|
|
203
|
+
private ws: GatewayWebSocketLike | null = null
|
|
204
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
205
|
+
private readonly heartbeat = createHeartbeatMonitor()
|
|
206
|
+
private sequence: number | null = null
|
|
207
|
+
private sessionId: string | undefined
|
|
208
|
+
private resumeGatewayUrl: string | undefined
|
|
209
|
+
private stopped = false
|
|
210
|
+
private attempt = 0
|
|
211
|
+
|
|
212
|
+
constructor(private readonly options: DiscordGatewayConnectOptions) {
|
|
213
|
+
this.sequence = options.resumeState?.sequence ?? null
|
|
214
|
+
this.sessionId = options.resumeState?.sessionId
|
|
215
|
+
this.resumeGatewayUrl = options.resumeState?.resumeGatewayUrl
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
open(): void {
|
|
219
|
+
if (this.stopped) return
|
|
220
|
+
const url = this.canResume() ? `${this.resumeGatewayUrl}?v=10&encoding=json` : DISCORD_GATEWAY_URL
|
|
221
|
+
const factory = this.options.webSocketFactory ?? defaultWebSocketFactory
|
|
222
|
+
const ws = factory(url)
|
|
223
|
+
this.ws = ws
|
|
224
|
+
ws.addEventListener('message', (event) => this.onMessage(event))
|
|
225
|
+
ws.addEventListener('close', (event) => this.onClose(event))
|
|
226
|
+
ws.addEventListener('error', () => {
|
|
227
|
+
/* the close handler drives reconnect; errors are logged by the worker */
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
stop(): void {
|
|
232
|
+
this.stopped = true
|
|
233
|
+
this.clearHeartbeat()
|
|
234
|
+
try {
|
|
235
|
+
this.ws?.close(1000, 'client stop')
|
|
236
|
+
} catch {
|
|
237
|
+
/* ignore */
|
|
238
|
+
}
|
|
239
|
+
this.ws = null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
isRunning(): boolean {
|
|
243
|
+
return !this.stopped
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private canResume(): boolean {
|
|
247
|
+
return Boolean(this.sessionId && this.resumeGatewayUrl)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Drop the resume state so the next connect performs a fresh `IDENTIFY`
|
|
252
|
+
* instead of a `RESUME` that Discord would reject again.
|
|
253
|
+
*/
|
|
254
|
+
private forgetSession(): void {
|
|
255
|
+
this.sessionId = undefined
|
|
256
|
+
this.resumeGatewayUrl = undefined
|
|
257
|
+
this.sequence = null
|
|
258
|
+
this.options.onResumeStateChange?.({ sessionId: undefined, sequence: null, resumeGatewayUrl: undefined })
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private onMessage(event: unknown): void {
|
|
262
|
+
const data = (event as { data?: unknown })?.data
|
|
263
|
+
if (typeof data !== 'string') return
|
|
264
|
+
let payload: { op: number; d?: unknown; s?: number | null; t?: string | null }
|
|
265
|
+
try {
|
|
266
|
+
payload = JSON.parse(data)
|
|
267
|
+
} catch {
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
if (typeof payload.s === 'number') {
|
|
271
|
+
this.sequence = payload.s
|
|
272
|
+
this.options.onResumeStateChange?.({
|
|
273
|
+
sessionId: this.sessionId,
|
|
274
|
+
sequence: this.sequence,
|
|
275
|
+
resumeGatewayUrl: this.resumeGatewayUrl,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
switch (payload.op) {
|
|
280
|
+
case GatewayOpcode.HELLO: {
|
|
281
|
+
const interval = (payload.d as { heartbeat_interval?: number } | undefined)?.heartbeat_interval ?? 41250
|
|
282
|
+
this.startHeartbeat(interval)
|
|
283
|
+
if (this.canResume()) {
|
|
284
|
+
this.sendJson(buildResumePayload(this.options.botToken, this.sessionId as string, this.sequence))
|
|
285
|
+
} else {
|
|
286
|
+
this.sendJson(buildIdentifyPayload(this.options.botToken, this.options.intents ?? DISCORD_GATEWAY_INTENTS))
|
|
287
|
+
}
|
|
288
|
+
break
|
|
289
|
+
}
|
|
290
|
+
case GatewayOpcode.HEARTBEAT: {
|
|
291
|
+
// Server-requested immediate heartbeat.
|
|
292
|
+
this.sendJson(buildHeartbeatPayload(this.sequence))
|
|
293
|
+
break
|
|
294
|
+
}
|
|
295
|
+
case GatewayOpcode.HEARTBEAT_ACK: {
|
|
296
|
+
this.heartbeat.onAck()
|
|
297
|
+
break
|
|
298
|
+
}
|
|
299
|
+
case GatewayOpcode.INVALID_SESSION: {
|
|
300
|
+
// `d` is Discord's `resumable` flag. When it is `false` the session is
|
|
301
|
+
// dead and the next connect MUST re-`IDENTIFY`; when it is `true` the
|
|
302
|
+
// stored session survives and the next connect may `RESUME`.
|
|
303
|
+
//
|
|
304
|
+
// Either way Discord stops serving this socket, so we have to close it
|
|
305
|
+
// and let `onClose` run the shared backoff + handshake path. Only
|
|
306
|
+
// clearing the resume state (the previous behaviour) left the socket
|
|
307
|
+
// open but permanently deaf: no dispatches, no reconnect, no error.
|
|
308
|
+
if (payload.d !== true) this.forgetSession()
|
|
309
|
+
this.clearHeartbeat()
|
|
310
|
+
try {
|
|
311
|
+
this.ws?.close(4000, 'invalid session')
|
|
312
|
+
} catch {
|
|
313
|
+
/* the close handler reconnects */
|
|
314
|
+
}
|
|
315
|
+
break
|
|
316
|
+
}
|
|
317
|
+
case GatewayOpcode.RECONNECT: {
|
|
318
|
+
try {
|
|
319
|
+
this.ws?.close(4000, 'server requested reconnect')
|
|
320
|
+
} catch {
|
|
321
|
+
/* the close handler reconnects */
|
|
322
|
+
}
|
|
323
|
+
break
|
|
324
|
+
}
|
|
325
|
+
case GatewayOpcode.DISPATCH: {
|
|
326
|
+
this.attempt = 0
|
|
327
|
+
void this.onDispatch(payload.t ?? '', payload.d)
|
|
328
|
+
break
|
|
329
|
+
}
|
|
330
|
+
default:
|
|
331
|
+
break
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private async onDispatch(type: string, data: unknown): Promise<void> {
|
|
336
|
+
if (type === 'READY') {
|
|
337
|
+
const ready = data as { session_id?: string; resume_gateway_url?: string; user?: { id?: string } }
|
|
338
|
+
this.sessionId = ready.session_id
|
|
339
|
+
this.resumeGatewayUrl = ready.resume_gateway_url
|
|
340
|
+
const resumeState: GatewayResumeState = {
|
|
341
|
+
sessionId: this.sessionId,
|
|
342
|
+
sequence: this.sequence,
|
|
343
|
+
resumeGatewayUrl: this.resumeGatewayUrl,
|
|
344
|
+
}
|
|
345
|
+
this.options.onResumeStateChange?.(resumeState)
|
|
346
|
+
await this.options.onReady({ botUserId: ready.user?.id ?? '', resumeState })
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
if (type === 'MESSAGE_CREATE') {
|
|
350
|
+
await this.options.onMessage(data as DiscordMessageObject)
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
if (type === 'MESSAGE_REACTION_ADD') {
|
|
354
|
+
await this.options.onReaction(data as Record<string, unknown>, 'added')
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
if (type === 'MESSAGE_REACTION_REMOVE') {
|
|
358
|
+
await this.options.onReaction(data as Record<string, unknown>, 'removed')
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private onClose(event: unknown): void {
|
|
364
|
+
this.clearHeartbeat()
|
|
365
|
+
const code = (event as { code?: number })?.code
|
|
366
|
+
if (isFatalGatewayCloseCode(code)) {
|
|
367
|
+
void this.options.onRequiresReauth({ code: code as number })
|
|
368
|
+
this.stopped = true
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
if (!shouldResumeAfterClose(code)) {
|
|
372
|
+
this.forgetSession()
|
|
373
|
+
}
|
|
374
|
+
if (this.stopped) return
|
|
375
|
+
const delay = computeReconnectDelayMs(this.attempt)
|
|
376
|
+
this.attempt += 1
|
|
377
|
+
setTimeout(() => this.open(), delay)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private startHeartbeat(intervalMs: number): void {
|
|
381
|
+
this.clearHeartbeat()
|
|
382
|
+
this.heartbeat.reset()
|
|
383
|
+
this.heartbeatTimer = setInterval(() => {
|
|
384
|
+
if (this.heartbeat.onBeat() === 'reconnect') {
|
|
385
|
+
// Previous heartbeat was never ACKed → zombie connection. Force a close
|
|
386
|
+
// so the close handler reconnects (with resume) instead of staying deaf.
|
|
387
|
+
this.clearHeartbeat()
|
|
388
|
+
try {
|
|
389
|
+
this.ws?.close(4000, 'heartbeat ack timeout')
|
|
390
|
+
} catch {
|
|
391
|
+
/* the close handler reconnects */
|
|
392
|
+
}
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
this.sendJson(buildHeartbeatPayload(this.sequence))
|
|
396
|
+
}, intervalMs)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private clearHeartbeat(): void {
|
|
400
|
+
if (this.heartbeatTimer) {
|
|
401
|
+
clearInterval(this.heartbeatTimer)
|
|
402
|
+
this.heartbeatTimer = null
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private sendJson(payload: unknown): void {
|
|
407
|
+
try {
|
|
408
|
+
this.ws?.send(JSON.stringify(payload))
|
|
409
|
+
} catch {
|
|
410
|
+
/* the close handler drives reconnect */
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function defaultWebSocketFactory(url: string): GatewayWebSocketLike {
|
|
416
|
+
const globalWebSocket = (globalThis as { WebSocket?: new (url: string) => GatewayWebSocketLike }).WebSocket
|
|
417
|
+
if (!globalWebSocket) {
|
|
418
|
+
throw new Error('[internal] global WebSocket is unavailable — Node >= 22 is required for the Discord gateway worker')
|
|
419
|
+
}
|
|
420
|
+
return new globalWebSocket(url)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let cachedClient: DiscordGatewayClient | null = null
|
|
424
|
+
|
|
425
|
+
export function getDiscordGatewayClient(): DiscordGatewayClient {
|
|
426
|
+
if (!cachedClient) cachedClient = new NativeDiscordGatewayClient()
|
|
427
|
+
return cachedClient
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function setDiscordGatewayClient(client: DiscordGatewayClient | null): void {
|
|
431
|
+
cachedClient = client
|
|
432
|
+
}
|