@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.
Files changed (220) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/build.mjs +7 -0
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +7 -0
  5. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js +86 -0
  6. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js.map +7 -0
  7. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js +39 -0
  8. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js.map +7 -0
  9. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js +97 -0
  10. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js.map +7 -0
  11. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js +65 -0
  12. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js.map +7 -0
  13. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js +110 -0
  14. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js.map +7 -0
  15. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js +103 -0
  16. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js.map +7 -0
  17. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js +93 -0
  18. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js.map +7 -0
  19. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js +52 -0
  20. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js.map +7 -0
  21. package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js +46 -0
  22. package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js.map +7 -0
  23. package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js +16 -0
  24. package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js.map +7 -0
  25. package/dist/modules/channel_discord/__integration__/meta.js +10 -0
  26. package/dist/modules/channel_discord/__integration__/meta.js.map +7 -0
  27. package/dist/modules/channel_discord/acl.js +15 -0
  28. package/dist/modules/channel_discord/acl.js.map +7 -0
  29. package/dist/modules/channel_discord/ai-agents.js +97 -0
  30. package/dist/modules/channel_discord/ai-agents.js.map +7 -0
  31. package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js +73 -0
  32. package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js.map +7 -0
  33. package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js +222 -0
  34. package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js.map +7 -0
  35. package/dist/modules/channel_discord/api/interactions/route.js +173 -0
  36. package/dist/modules/channel_discord/api/interactions/route.js.map +7 -0
  37. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js +196 -0
  38. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js.map +7 -0
  39. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js +14 -0
  40. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js.map +7 -0
  41. package/dist/modules/channel_discord/cli.js +142 -0
  42. package/dist/modules/channel_discord/cli.js.map +7 -0
  43. package/dist/modules/channel_discord/commands/ai-reply-proposal.js +116 -0
  44. package/dist/modules/channel_discord/commands/ai-reply-proposal.js.map +7 -0
  45. package/dist/modules/channel_discord/commands/update-ai-auto-reply.js +104 -0
  46. package/dist/modules/channel_discord/commands/update-ai-auto-reply.js.map +7 -0
  47. package/dist/modules/channel_discord/di.js +23 -0
  48. package/dist/modules/channel_discord/di.js.map +7 -0
  49. package/dist/modules/channel_discord/index.js +9 -0
  50. package/dist/modules/channel_discord/index.js.map +7 -0
  51. package/dist/modules/channel_discord/integration.js +96 -0
  52. package/dist/modules/channel_discord/integration.js.map +7 -0
  53. package/dist/modules/channel_discord/lib/adapter.js +248 -0
  54. package/dist/modules/channel_discord/lib/adapter.js.map +7 -0
  55. package/dist/modules/channel_discord/lib/ai-agent-directory.js +45 -0
  56. package/dist/modules/channel_discord/lib/ai-agent-directory.js.map +7 -0
  57. package/dist/modules/channel_discord/lib/ai-features.js +9 -0
  58. package/dist/modules/channel_discord/lib/ai-features.js.map +7 -0
  59. package/dist/modules/channel_discord/lib/ai-proposal-contract.js +9 -0
  60. package/dist/modules/channel_discord/lib/ai-proposal-contract.js.map +7 -0
  61. package/dist/modules/channel_discord/lib/ai-proposal.js +109 -0
  62. package/dist/modules/channel_discord/lib/ai-proposal.js.map +7 -0
  63. package/dist/modules/channel_discord/lib/ai-reply.js +96 -0
  64. package/dist/modules/channel_discord/lib/ai-reply.js.map +7 -0
  65. package/dist/modules/channel_discord/lib/ai-service-principal.js +70 -0
  66. package/dist/modules/channel_discord/lib/ai-service-principal.js.map +7 -0
  67. package/dist/modules/channel_discord/lib/capabilities.js +42 -0
  68. package/dist/modules/channel_discord/lib/capabilities.js.map +7 -0
  69. package/dist/modules/channel_discord/lib/channel-access.js +62 -0
  70. package/dist/modules/channel_discord/lib/channel-access.js.map +7 -0
  71. package/dist/modules/channel_discord/lib/channel-state-store.js +103 -0
  72. package/dist/modules/channel_discord/lib/channel-state-store.js.map +7 -0
  73. package/dist/modules/channel_discord/lib/convert-outbound.js +38 -0
  74. package/dist/modules/channel_discord/lib/convert-outbound.js.map +7 -0
  75. package/dist/modules/channel_discord/lib/credentials.js +61 -0
  76. package/dist/modules/channel_discord/lib/credentials.js.map +7 -0
  77. package/dist/modules/channel_discord/lib/discord-gateway-client.js +289 -0
  78. package/dist/modules/channel_discord/lib/discord-gateway-client.js.map +7 -0
  79. package/dist/modules/channel_discord/lib/discord-rest.js +203 -0
  80. package/dist/modules/channel_discord/lib/discord-rest.js.map +7 -0
  81. package/dist/modules/channel_discord/lib/failure-reason.js +30 -0
  82. package/dist/modules/channel_discord/lib/failure-reason.js.map +7 -0
  83. package/dist/modules/channel_discord/lib/gateway-bridge.js +42 -0
  84. package/dist/modules/channel_discord/lib/gateway-bridge.js.map +7 -0
  85. package/dist/modules/channel_discord/lib/health.js +37 -0
  86. package/dist/modules/channel_discord/lib/health.js.map +7 -0
  87. package/dist/modules/channel_discord/lib/interactions-dispatch.js +164 -0
  88. package/dist/modules/channel_discord/lib/interactions-dispatch.js.map +7 -0
  89. package/dist/modules/channel_discord/lib/interactions-handler.js +115 -0
  90. package/dist/modules/channel_discord/lib/interactions-handler.js.map +7 -0
  91. package/dist/modules/channel_discord/lib/interactions-queue.js +17 -0
  92. package/dist/modules/channel_discord/lib/interactions-queue.js.map +7 -0
  93. package/dist/modules/channel_discord/lib/interactions-verify.js +84 -0
  94. package/dist/modules/channel_discord/lib/interactions-verify.js.map +7 -0
  95. package/dist/modules/channel_discord/lib/normalize-inbound.js +59 -0
  96. package/dist/modules/channel_discord/lib/normalize-inbound.js.map +7 -0
  97. package/dist/modules/channel_discord/lib/preset.js +63 -0
  98. package/dist/modules/channel_discord/lib/preset.js.map +7 -0
  99. package/dist/modules/channel_discord/lib/queue-strategy.js +12 -0
  100. package/dist/modules/channel_discord/lib/queue-strategy.js.map +7 -0
  101. package/dist/modules/channel_discord/lib/slash-commands.js +42 -0
  102. package/dist/modules/channel_discord/lib/slash-commands.js.map +7 -0
  103. package/dist/modules/channel_discord/message-types.js +64 -0
  104. package/dist/modules/channel_discord/message-types.js.map +7 -0
  105. package/dist/modules/channel_discord/setup.js +62 -0
  106. package/dist/modules/channel_discord/setup.js.map +7 -0
  107. package/dist/modules/channel_discord/subscribers/ai-auto-reply.js +185 -0
  108. package/dist/modules/channel_discord/subscribers/ai-auto-reply.js.map +7 -0
  109. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js +79 -0
  110. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js.map +7 -0
  111. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js +20 -0
  112. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js.map +7 -0
  113. package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js +220 -0
  114. package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js.map +7 -0
  115. package/dist/modules/channel_discord/widgets/injection/connect/widget.js +17 -0
  116. package/dist/modules/channel_discord/widgets/injection/connect/widget.js.map +7 -0
  117. package/dist/modules/channel_discord/widgets/injection-table.js +26 -0
  118. package/dist/modules/channel_discord/widgets/injection-table.js.map +7 -0
  119. package/dist/modules/channel_discord/workers/discord-gateway.js +270 -0
  120. package/dist/modules/channel_discord/workers/discord-gateway.js.map +7 -0
  121. package/dist/modules/channel_discord/workers/discord-interactions.js +113 -0
  122. package/dist/modules/channel_discord/workers/discord-interactions.js.map +7 -0
  123. package/jest.config.cjs +38 -0
  124. package/package.json +104 -0
  125. package/src/index.ts +1 -0
  126. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.ts +109 -0
  127. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.ts +58 -0
  128. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.ts +175 -0
  129. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.ts +83 -0
  130. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.ts +156 -0
  131. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.ts +144 -0
  132. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.ts +119 -0
  133. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.ts +75 -0
  134. package/src/modules/channel_discord/__integration__/helpers/discordSignature.ts +77 -0
  135. package/src/modules/channel_discord/__integration__/helpers/eventsQueue.ts +26 -0
  136. package/src/modules/channel_discord/__integration__/meta.ts +6 -0
  137. package/src/modules/channel_discord/__tests__/ai-auto-reply.policy.integration.test.ts +286 -0
  138. package/src/modules/channel_discord/acl.ts +19 -0
  139. package/src/modules/channel_discord/ai-agents.ts +157 -0
  140. package/src/modules/channel_discord/api/ai-auto-reply/channels/__tests__/route.test.ts +175 -0
  141. package/src/modules/channel_discord/api/ai-auto-reply/channels/route.ts +99 -0
  142. package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/__tests__/route.test.ts +311 -0
  143. package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.ts +299 -0
  144. package/src/modules/channel_discord/api/interactions/route.ts +242 -0
  145. package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.ts +18 -0
  146. package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.tsx +288 -0
  147. package/src/modules/channel_discord/cli.ts +234 -0
  148. package/src/modules/channel_discord/commands/__tests__/ai-reply-proposal.test.ts +155 -0
  149. package/src/modules/channel_discord/commands/__tests__/update-ai-auto-reply.test.ts +185 -0
  150. package/src/modules/channel_discord/commands/ai-reply-proposal.ts +162 -0
  151. package/src/modules/channel_discord/commands/update-ai-auto-reply.ts +157 -0
  152. package/src/modules/channel_discord/di.ts +26 -0
  153. package/src/modules/channel_discord/i18n/de.json +57 -0
  154. package/src/modules/channel_discord/i18n/en.json +57 -0
  155. package/src/modules/channel_discord/i18n/es.json +57 -0
  156. package/src/modules/channel_discord/i18n/ko.json +57 -0
  157. package/src/modules/channel_discord/i18n/pl.json +57 -0
  158. package/src/modules/channel_discord/index.ts +6 -0
  159. package/src/modules/channel_discord/integration.ts +98 -0
  160. package/src/modules/channel_discord/lib/__tests__/adapter.test.ts +206 -0
  161. package/src/modules/channel_discord/lib/__tests__/ai-agent-directory.test.ts +61 -0
  162. package/src/modules/channel_discord/lib/__tests__/ai-reply.test.ts +115 -0
  163. package/src/modules/channel_discord/lib/__tests__/capabilities.test.ts +170 -0
  164. package/src/modules/channel_discord/lib/__tests__/channel-quarantine.test.ts +89 -0
  165. package/src/modules/channel_discord/lib/__tests__/channel-state-store.auto-reply-marker.test.ts +145 -0
  166. package/src/modules/channel_discord/lib/__tests__/channel-state-store.test.ts +78 -0
  167. package/src/modules/channel_discord/lib/__tests__/convert-outbound.test.ts +39 -0
  168. package/src/modules/channel_discord/lib/__tests__/discord-gateway-client.test.ts +243 -0
  169. package/src/modules/channel_discord/lib/__tests__/failure-reason.test.ts +68 -0
  170. package/src/modules/channel_discord/lib/__tests__/gateway-bridge.test.ts +62 -0
  171. package/src/modules/channel_discord/lib/__tests__/interactions-dispatch.test.ts +223 -0
  172. package/src/modules/channel_discord/lib/__tests__/interactions-handler.test.ts +469 -0
  173. package/src/modules/channel_discord/lib/__tests__/interactions-verify.test.ts +126 -0
  174. package/src/modules/channel_discord/lib/__tests__/normalize-inbound.identity.test.ts +64 -0
  175. package/src/modules/channel_discord/lib/__tests__/normalize-inbound.test.ts +67 -0
  176. package/src/modules/channel_discord/lib/__tests__/preset.test.ts +110 -0
  177. package/src/modules/channel_discord/lib/__tests__/queue-strategy.test.ts +33 -0
  178. package/src/modules/channel_discord/lib/adapter.ts +359 -0
  179. package/src/modules/channel_discord/lib/ai-agent-directory.ts +124 -0
  180. package/src/modules/channel_discord/lib/ai-features.ts +23 -0
  181. package/src/modules/channel_discord/lib/ai-proposal-contract.ts +24 -0
  182. package/src/modules/channel_discord/lib/ai-proposal.ts +179 -0
  183. package/src/modules/channel_discord/lib/ai-reply.ts +179 -0
  184. package/src/modules/channel_discord/lib/ai-service-principal.ts +143 -0
  185. package/src/modules/channel_discord/lib/capabilities.ts +104 -0
  186. package/src/modules/channel_discord/lib/channel-access.ts +110 -0
  187. package/src/modules/channel_discord/lib/channel-state-store.ts +217 -0
  188. package/src/modules/channel_discord/lib/convert-outbound.ts +88 -0
  189. package/src/modules/channel_discord/lib/credentials.ts +88 -0
  190. package/src/modules/channel_discord/lib/discord-gateway-client.ts +432 -0
  191. package/src/modules/channel_discord/lib/discord-rest.ts +393 -0
  192. package/src/modules/channel_discord/lib/failure-reason.ts +49 -0
  193. package/src/modules/channel_discord/lib/gateway-bridge.ts +79 -0
  194. package/src/modules/channel_discord/lib/health.ts +55 -0
  195. package/src/modules/channel_discord/lib/interactions-dispatch.ts +288 -0
  196. package/src/modules/channel_discord/lib/interactions-handler.ts +267 -0
  197. package/src/modules/channel_discord/lib/interactions-queue.ts +52 -0
  198. package/src/modules/channel_discord/lib/interactions-verify.ts +174 -0
  199. package/src/modules/channel_discord/lib/normalize-inbound.ts +89 -0
  200. package/src/modules/channel_discord/lib/preset.ts +109 -0
  201. package/src/modules/channel_discord/lib/queue-strategy.ts +29 -0
  202. package/src/modules/channel_discord/lib/slash-commands.ts +57 -0
  203. package/src/modules/channel_discord/message-types.ts +75 -0
  204. package/src/modules/channel_discord/setup.ts +78 -0
  205. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.armed.test.ts +149 -0
  206. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.failure-marker.test.ts +133 -0
  207. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.test.ts +273 -0
  208. package/src/modules/channel_discord/subscribers/ai-auto-reply.ts +325 -0
  209. package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.tsx +143 -0
  210. package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.ts +19 -0
  211. package/src/modules/channel_discord/widgets/injection/connect/widget.client.tsx +237 -0
  212. package/src/modules/channel_discord/widgets/injection/connect/widget.ts +16 -0
  213. package/src/modules/channel_discord/widgets/injection-table.ts +24 -0
  214. package/src/modules/channel_discord/workers/__tests__/discord-gateway.single-identify.test.ts +88 -0
  215. package/src/modules/channel_discord/workers/__tests__/discord-gateway.test.ts +179 -0
  216. package/src/modules/channel_discord/workers/__tests__/discord-interactions.test.ts +152 -0
  217. package/src/modules/channel_discord/workers/discord-gateway.ts +458 -0
  218. package/src/modules/channel_discord/workers/discord-interactions.ts +162 -0
  219. package/tsconfig.json +9 -0
  220. 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
+ }