@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,103 @@
1
+ import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
2
+ import { CommunicationChannel } from "@open-mercato/core/modules/communication_channels/data/entities";
3
+ import { discordChannelStateSchema } from "./credentials.js";
4
+ function mergeDiscordChannelState(currentRaw, patch, now) {
5
+ const current = discordChannelStateSchema.parse(currentRaw ?? {});
6
+ const sameSession = Boolean(patch.sessionId) && patch.sessionId === current.sessionId;
7
+ const rewindsSequence = sameSession && typeof patch.sequence === "number" && typeof current.sequence === "number" && patch.sequence < current.sequence;
8
+ if (rewindsSequence) return null;
9
+ return {
10
+ ...current,
11
+ ...patch,
12
+ lastConnectedAt: now.toISOString()
13
+ };
14
+ }
15
+ async function persistDiscordChannelState(params) {
16
+ const fork = params.em.fork();
17
+ const channel = await findOneWithDecryption(
18
+ fork,
19
+ CommunicationChannel,
20
+ {
21
+ id: params.channelId,
22
+ tenantId: params.scope.tenantId,
23
+ organizationId: params.scope.organizationId,
24
+ deletedAt: null
25
+ },
26
+ void 0,
27
+ {
28
+ tenantId: params.scope.tenantId,
29
+ organizationId: params.scope.organizationId ?? params.scope.tenantId
30
+ }
31
+ );
32
+ if (!channel) return "not_found";
33
+ const merged = mergeDiscordChannelState(channel.channelState, params.patch, params.now ?? /* @__PURE__ */ new Date());
34
+ if (!merged) return "skipped";
35
+ channel.channelState = merged;
36
+ await fork.flush();
37
+ return "written";
38
+ }
39
+ const AUTO_REPLY_ERROR_MAX_LENGTH = 500;
40
+ async function recordDiscordAutoReplyOutcome(params) {
41
+ const fork = params.em.fork();
42
+ const channel = await findOneWithDecryption(
43
+ fork,
44
+ CommunicationChannel,
45
+ {
46
+ id: params.channelId,
47
+ tenantId: params.scope.tenantId,
48
+ organizationId: params.scope.organizationId,
49
+ deletedAt: null
50
+ },
51
+ void 0,
52
+ {
53
+ tenantId: params.scope.tenantId,
54
+ organizationId: params.scope.organizationId ?? params.scope.tenantId
55
+ }
56
+ );
57
+ if (!channel) return "not_found";
58
+ const current = discordChannelStateSchema.parse(channel.channelState ?? {});
59
+ const failure = params.failure?.slice(0, AUTO_REPLY_ERROR_MAX_LENGTH) ?? null;
60
+ if (failure === null && current.aiAutoReplyLastError === void 0) return "unchanged";
61
+ if (failure !== null && current.aiAutoReplyLastError === failure) return "unchanged";
62
+ const next = { ...current };
63
+ if (failure === null) {
64
+ delete next.aiAutoReplyLastError;
65
+ delete next.aiAutoReplyLastErrorAt;
66
+ } else {
67
+ next.aiAutoReplyLastError = failure;
68
+ next.aiAutoReplyLastErrorAt = (params.now ?? /* @__PURE__ */ new Date()).toISOString();
69
+ }
70
+ channel.channelState = next;
71
+ await fork.flush();
72
+ return "written";
73
+ }
74
+ async function quarantineDiscordChannel(params) {
75
+ const fork = params.em.fork();
76
+ const channel = await findOneWithDecryption(
77
+ fork,
78
+ CommunicationChannel,
79
+ {
80
+ id: params.channelId,
81
+ tenantId: params.scope.tenantId,
82
+ organizationId: params.scope.organizationId,
83
+ deletedAt: null
84
+ },
85
+ void 0,
86
+ {
87
+ tenantId: params.scope.tenantId,
88
+ organizationId: params.scope.organizationId ?? params.scope.tenantId
89
+ }
90
+ );
91
+ if (!channel) return "not_found";
92
+ channel.status = "requires_reauth";
93
+ channel.lastError = params.reason;
94
+ await fork.flush();
95
+ return "quarantined";
96
+ }
97
+ export {
98
+ mergeDiscordChannelState,
99
+ persistDiscordChannelState,
100
+ quarantineDiscordChannel,
101
+ recordDiscordAutoReplyOutcome
102
+ };
103
+ //# sourceMappingURL=channel-state-store.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_discord/lib/channel-state-store.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'\nimport { discordChannelStateSchema, type DiscordChannelState } from './credentials'\n\n/**\n * Tenant/organization scope every Discord channel-state write MUST carry.\n * The gateway worker persists resume state from a WebSocket callback that fires\n * long after the job handler returned, so the scope is captured up front from\n * the channel row the job loaded and replayed on every write.\n */\nexport type DiscordChannelScope = {\n tenantId: string\n organizationId: string | null\n}\n\n/** The gateway-owned subset of `channelState` this store is allowed to write. */\nexport type DiscordChannelStatePatch = {\n sessionId?: string\n sequence?: number | null\n resumeGatewayUrl?: string\n botUserId?: string\n}\n\nexport type PersistDiscordChannelStateResult = 'written' | 'skipped' | 'not_found'\n\n/**\n * Merge a gateway resume-state patch onto the state currently stored on the\n * channel row, or return `null` when the patch must not be written.\n *\n * Two rules make this safe against the socket callbacks racing each other:\n *\n * - Only the gateway-owned keys are touched; every other key (`aiAutoReplyEnabled`,\n * `aiAgentId`, operator-set fields) is carried forward from the row that was\n * just read, so a concurrent UI edit is never clobbered wholesale.\n * - A patch that would rewind the sequence of the session already stored is\n * dropped. A late `READY` callback from a superseded socket must not overwrite\n * fresher resume state with a stale cursor.\n */\nexport function mergeDiscordChannelState(\n currentRaw: unknown,\n patch: DiscordChannelStatePatch,\n now: Date,\n): DiscordChannelState | null {\n const current = discordChannelStateSchema.parse(currentRaw ?? {})\n const sameSession = Boolean(patch.sessionId) && patch.sessionId === current.sessionId\n const rewindsSequence =\n sameSession &&\n typeof patch.sequence === 'number' &&\n typeof current.sequence === 'number' &&\n patch.sequence < current.sequence\n if (rewindsSequence) return null\n\n return {\n ...current,\n ...patch,\n lastConnectedAt: now.toISOString(),\n }\n}\n\n/**\n * Scoped read-modify-write of a Discord channel's gateway resume state.\n *\n * The lookup filters by `tenant_id` / `organization_id` (never by id alone) so a\n * socket callback can only ever touch the row its own channel scope owns, and it\n * runs on its own `EntityManager` fork so the flush never rides on the request\n * context the worker job was started from.\n */\nexport async function persistDiscordChannelState(params: {\n em: EntityManager\n channelId: string\n scope: DiscordChannelScope\n patch: DiscordChannelStatePatch\n now?: Date\n}): Promise<PersistDiscordChannelStateResult> {\n const fork = params.em.fork()\n const channel = await findOneWithDecryption(\n fork,\n CommunicationChannel,\n {\n id: params.channelId,\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId,\n deletedAt: null,\n },\n undefined,\n {\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId ?? params.scope.tenantId,\n },\n )\n if (!channel) return 'not_found'\n\n const merged = mergeDiscordChannelState(channel.channelState, params.patch, params.now ?? new Date())\n if (!merged) return 'skipped'\n\n channel.channelState = merged\n await fork.flush()\n return 'written'\n}\n\nexport type RecordAutoReplyOutcomeResult = 'written' | 'unchanged' | 'not_found'\n\n/** Truncated so a provider stack trace cannot grow the JSONB column without bound. */\nconst AUTO_REPLY_ERROR_MAX_LENGTH = 500\n\n/**\n * Record \u2014 or clear \u2014 why the last AI auto-reply attempt on this channel produced\n * nothing.\n *\n * The subscriber degrades every failure to a no-op on purpose: a broken model, a\n * denied policy check or a malformed object must never turn into a send. Being\n * *silent* about it is the part that hurts, because the operator's surface still\n * reads \"Auto-reply on\" while the channel answers nothing, and a `logger.warn` in\n * a background subscriber is not somewhere anyone looks. A save-time check cannot\n * close this on its own either \u2014 the settings route validates the agent against\n * the auto-reply principal, but a role edited afterwards makes that verdict stale.\n *\n * `failure: null` clears the marker, and the caller is expected to skip the write\n * when there is nothing to clear \u2014 a successful reply on a healthy channel must\n * not cost a row update per inbound message.\n *\n * The channel row's own `lastError` is deliberately left alone: the hub owns it\n * for delivery and polling failures, clears it on a successful poll, and keys the\n * reauth banner off `status`. Borrowing it here would let an AI failure mask a\n * delivery failure and let a successful poll erase an AI one.\n */\nexport async function recordDiscordAutoReplyOutcome(params: {\n em: EntityManager\n channelId: string\n scope: DiscordChannelScope\n failure: string | null\n now?: Date\n}): Promise<RecordAutoReplyOutcomeResult> {\n const fork = params.em.fork()\n const channel = await findOneWithDecryption(\n fork,\n CommunicationChannel,\n {\n id: params.channelId,\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId,\n deletedAt: null,\n },\n undefined,\n {\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId ?? params.scope.tenantId,\n },\n )\n if (!channel) return 'not_found'\n\n const current = discordChannelStateSchema.parse(channel.channelState ?? {})\n const failure = params.failure?.slice(0, AUTO_REPLY_ERROR_MAX_LENGTH) ?? null\n if (failure === null && current.aiAutoReplyLastError === undefined) return 'unchanged'\n if (failure !== null && current.aiAutoReplyLastError === failure) return 'unchanged'\n\n const next: DiscordChannelState = { ...current }\n if (failure === null) {\n delete next.aiAutoReplyLastError\n delete next.aiAutoReplyLastErrorAt\n } else {\n next.aiAutoReplyLastError = failure\n next.aiAutoReplyLastErrorAt = (params.now ?? new Date()).toISOString()\n }\n\n channel.channelState = next\n await fork.flush()\n return 'written'\n}\n\nexport type QuarantineDiscordChannelResult = 'quarantined' | 'not_found'\n\n/**\n * Park a channel Discord has fatally rejected so the reconciler stops re-opening it.\n *\n * A `4004` (invalid token) or `4014` (disallowed intents) close is not\n * recoverable by retrying: the token or the Developer Portal toggle has to\n * change first. Without a persisted status the refresh job sees \"no live\n * session\" on the next tick and IDENTIFYs again \u2014 at the CLI's default 60s\n * refresh that is ~1440 session starts per day against Discord's ~1000/day\n * per-bot budget, while the row and the admin UI still read `connected`.\n *\n * Writing `requires_reauth` mirrors what the hub already does for a channel\n * whose credentials could not be persisted (`connect-channel.ts`), so the\n * existing reauth banner, the mutation guard that keys on `status`, and the\n * operator's reconnect flow all engage without any hub change.\n */\nexport async function quarantineDiscordChannel(params: {\n em: EntityManager\n channelId: string\n scope: DiscordChannelScope\n reason: string\n}): Promise<QuarantineDiscordChannelResult> {\n const fork = params.em.fork()\n const channel = await findOneWithDecryption(\n fork,\n CommunicationChannel,\n {\n id: params.channelId,\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId,\n deletedAt: null,\n },\n undefined,\n {\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId ?? params.scope.tenantId,\n },\n )\n if (!channel) return 'not_found'\n\n channel.status = 'requires_reauth'\n channel.lastError = params.reason\n await fork.flush()\n return 'quarantined'\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,38 @@
1
+ import { createLogger } from "@open-mercato/shared/lib/logger";
2
+ import { DISCORD_MAX_BODY_LENGTH } from "./capabilities.js";
3
+ const logger = createLogger("channel_discord").child({ component: "convert-outbound" });
4
+ function htmlToMarkdown(html) {
5
+ return html.replace(/<\s*br\s*\/?\s*>/gi, "\n").replace(/<\s*\/\s*p\s*>/gi, "\n\n").replace(/<\s*p[^>]*>/gi, "").replace(/<\s*(strong|b)\s*>(.*?)<\s*\/\s*\1\s*>/gis, "**$2**").replace(/<\s*(em|i)\s*>(.*?)<\s*\/\s*\1\s*>/gis, "*$2*").replace(/<\s*code\s*>(.*?)<\s*\/\s*code\s*>/gis, "`$1`").replace(/<\s*a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\s*\/\s*a\s*>/gis, "[$2]($1)").replace(/<[^>]+>/g, "").replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/\n{3,}/g, "\n\n").trim();
6
+ }
7
+ async function convertOutboundForDiscord(input) {
8
+ const droppedAttachmentCount = input.attachments?.length ?? 0;
9
+ if (droppedAttachmentCount > 0) {
10
+ logger.warn("discord outbound attachments are not supported \u2014 sending text only", {
11
+ droppedAttachmentCount
12
+ });
13
+ }
14
+ const raw = input.body ?? "";
15
+ const markdown = input.bodyFormat === "html" ? htmlToMarkdown(raw) : raw;
16
+ const TRUNCATE_MARKER = "\u2026";
17
+ const content = markdown.length > DISCORD_MAX_BODY_LENGTH ? markdown.slice(0, DISCORD_MAX_BODY_LENGTH - TRUNCATE_MARKER.length) + TRUNCATE_MARKER : markdown;
18
+ const allowedMentions = input.channelMetadata?.allowedMentions ?? { parse: [] };
19
+ return {
20
+ content: {
21
+ text: content,
22
+ bodyFormat: "markdown"
23
+ },
24
+ metadata: {
25
+ allowedMentions,
26
+ droppedAttachmentCount,
27
+ // Reply threading, dormant: no hub producer writes `replyToExternalId`
28
+ // into outbound metadata today, which is why `capabilities.threading` is
29
+ // declared `false`. Kept so that flag flips back with the hub-side
30
+ // producer and nothing else.
31
+ messageReferenceId: typeof input.channelMetadata?.replyToExternalId === "string" ? input.channelMetadata.replyToExternalId : void 0
32
+ }
33
+ };
34
+ }
35
+ export {
36
+ convertOutboundForDiscord
37
+ };
38
+ //# sourceMappingURL=convert-outbound.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_discord/lib/convert-outbound.ts"],
4
+ "sourcesContent": ["import type {\n ChannelNativeContent,\n ConvertOutboundInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { DISCORD_MAX_BODY_LENGTH } from './capabilities'\n\nconst logger = createLogger('channel_discord').child({ component: 'convert-outbound' })\n\n/**\n * Very small HTML \u2192 markdown down-converter for the common inline tags the hub's\n * `html` body format produces. Discord content is markdown-native, so we map the\n * handful of tags that have a markdown equivalent and strip the rest rather than\n * pulling in a full HTML parser dependency.\n */\nfunction htmlToMarkdown(html: string): string {\n return html\n .replace(/<\\s*br\\s*\\/?\\s*>/gi, '\\n')\n .replace(/<\\s*\\/\\s*p\\s*>/gi, '\\n\\n')\n .replace(/<\\s*p[^>]*>/gi, '')\n .replace(/<\\s*(strong|b)\\s*>(.*?)<\\s*\\/\\s*\\1\\s*>/gis, '**$2**')\n .replace(/<\\s*(em|i)\\s*>(.*?)<\\s*\\/\\s*\\1\\s*>/gis, '*$2*')\n .replace(/<\\s*code\\s*>(.*?)<\\s*\\/\\s*code\\s*>/gis, '`$1`')\n .replace(/<\\s*a[^>]*href=[\"']([^\"']+)[\"'][^>]*>(.*?)<\\s*\\/\\s*a\\s*>/gis, '[$2]($1)')\n .replace(/<[^>]+>/g, '')\n .replace(/&nbsp;/gi, ' ')\n .replace(/&amp;/gi, '&')\n .replace(/&lt;/gi, '<')\n .replace(/&gt;/gi, '>')\n .replace(/&quot;/gi, '\"')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n}\n\n/**\n * Convert the hub's normalized outbound body into Discord native content.\n *\n * - `markdown` / `text` pass through unchanged (Discord is markdown-native).\n * - `html` is down-converted to markdown.\n * - Content is clamped to Discord's 2000-char hard limit (a longer body is\n * truncated with an ellipsis marker rather than rejected by the API).\n * - `allowed_mentions` defaults to `{ parse: [] }` so an AI/automated reply can\n * never accidentally @-ping everyone; callers can widen it via `channelMetadata`.\n * - Attachments are NOT uploaded: `discord-rest` has no multipart upload, so the\n * capability profile declares `fileSharing: false` and the hub should never\n * route an attachment here. If one arrives anyway we log it rather than drop\n * it silently, and report the count in `metadata.droppedAttachmentCount` so the\n * caller can surface the gap.\n */\nexport async function convertOutboundForDiscord(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n const droppedAttachmentCount = input.attachments?.length ?? 0\n if (droppedAttachmentCount > 0) {\n logger.warn('discord outbound attachments are not supported \u2014 sending text only', {\n droppedAttachmentCount,\n })\n }\n\n const raw = input.body ?? ''\n const markdown = input.bodyFormat === 'html' ? htmlToMarkdown(raw) : raw\n\n const TRUNCATE_MARKER = '\u2026'\n const content =\n markdown.length > DISCORD_MAX_BODY_LENGTH\n ? markdown.slice(0, DISCORD_MAX_BODY_LENGTH - TRUNCATE_MARKER.length) + TRUNCATE_MARKER\n : markdown\n\n const allowedMentions =\n (input.channelMetadata?.allowedMentions as Record<string, unknown> | undefined) ?? { parse: [] }\n\n return {\n content: {\n text: content,\n bodyFormat: 'markdown',\n },\n metadata: {\n allowedMentions,\n droppedAttachmentCount,\n // Reply threading, dormant: no hub producer writes `replyToExternalId`\n // into outbound metadata today, which is why `capabilities.threading` is\n // declared `false`. Kept so that flag flips back with the hub-side\n // producer and nothing else.\n messageReferenceId:\n typeof input.channelMetadata?.replyToExternalId === 'string'\n ? (input.channelMetadata.replyToExternalId as string)\n : undefined,\n },\n }\n}\n"],
5
+ "mappings": "AAIA,SAAS,oBAAoB;AAC7B,SAAS,+BAA+B;AAExC,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,mBAAmB,CAAC;AAQtF,SAAS,eAAe,MAAsB;AAC5C,SAAO,KACJ,QAAQ,sBAAsB,IAAI,EAClC,QAAQ,oBAAoB,MAAM,EAClC,QAAQ,iBAAiB,EAAE,EAC3B,QAAQ,6CAA6C,QAAQ,EAC7D,QAAQ,yCAAyC,MAAM,EACvD,QAAQ,yCAAyC,MAAM,EACvD,QAAQ,+DAA+D,UAAU,EACjF,QAAQ,YAAY,EAAE,EACtB,QAAQ,YAAY,GAAG,EACvB,QAAQ,WAAW,GAAG,EACtB,QAAQ,UAAU,GAAG,EACrB,QAAQ,UAAU,GAAG,EACrB,QAAQ,YAAY,GAAG,EACvB,QAAQ,WAAW,MAAM,EACzB,KAAK;AACV;AAiBA,eAAsB,0BAA0B,OAA4D;AAC1G,QAAM,yBAAyB,MAAM,aAAa,UAAU;AAC5D,MAAI,yBAAyB,GAAG;AAC9B,WAAO,KAAK,2EAAsE;AAAA,MAChF;AAAA,IACF,CAAC;AAAA,EACH;AAEA,QAAM,MAAM,MAAM,QAAQ;AAC1B,QAAM,WAAW,MAAM,eAAe,SAAS,eAAe,GAAG,IAAI;AAErE,QAAM,kBAAkB;AACxB,QAAM,UACJ,SAAS,SAAS,0BACd,SAAS,MAAM,GAAG,0BAA0B,gBAAgB,MAAM,IAAI,kBACtE;AAEN,QAAM,kBACH,MAAM,iBAAiB,mBAA2D,EAAE,OAAO,CAAC,EAAE;AAEjG,SAAO;AAAA,IACL,SAAS;AAAA,MACP,MAAM;AAAA,MACN,YAAY;AAAA,IACd;AAAA,IACA,UAAU;AAAA,MACR;AAAA,MACA;AAAA;AAAA;AAAA;AAAA;AAAA,MAKA,oBACE,OAAO,MAAM,iBAAiB,sBAAsB,WAC/C,MAAM,gBAAgB,oBACvB;AAAA,IACR;AAAA,EACF;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ const discordCredentialsSchema = z.object({
3
+ // "Bot <token>" — used as `Authorization: Bot <token>` on every REST call and
4
+ // for the gateway Identify handshake. Never logged.
5
+ botToken: z.string().min(1, "Bot token required"),
6
+ // Application (client) id — needed to register slash commands + build invites.
7
+ applicationId: z.string().min(1, "Application ID required"),
8
+ // Ed25519 public key (hex) from the application's General Information tab —
9
+ // verifies signed interaction requests. Never used as a secret; safe to store.
10
+ publicKey: z.string().min(1, "Public key required").regex(/^[0-9a-fA-F]+$/, "Public key must be hex").refine((value) => value.length === 64, "Public key must be a 32-byte (64 hex char) Ed25519 key"),
11
+ // Scope the bot to one guild (recommended). Optional.
12
+ guildId: z.string().optional(),
13
+ // Default outbound text channel id (used by the test-send smoke test).
14
+ defaultChannelId: z.string().optional()
15
+ }).passthrough();
16
+ const discordChannelStateSchema = z.object({
17
+ sessionId: z.string().optional(),
18
+ sequence: z.number().nullable().optional(),
19
+ resumeGatewayUrl: z.string().optional(),
20
+ // Bot's own user id — cached after the READY event so the worker can drop
21
+ // events it authored (feedback-loop guard) without an extra REST call.
22
+ botUserId: z.string().optional(),
23
+ lastConnectedAt: z.string().optional(),
24
+ // Per-channel AI auto-reply toggle (default OFF). When truthy, the AI
25
+ // auto-reply subscriber may answer "easy" inbound messages and proposes a
26
+ // reply for everything else. Written by the channel's AI auto-reply settings
27
+ // form via `PUT /api/channel_discord/channels/{id}/ai-auto-reply`.
28
+ aiAutoReplyEnabled: z.boolean().optional(),
29
+ // Object-mode agent id invoked when auto-reply is enabled. Defaults to the
30
+ // provider's own `channel_discord.auto_reply` agent; a tenant may point it at
31
+ // any object-mode agent whose `requiredFeatures` its channel-bot user holds.
32
+ aiAgentId: z.string().optional(),
33
+ // Why the last auto-reply attempt produced nothing, cleared by the next
34
+ // attempt that gets somewhere. The settings route validates a channel's agent
35
+ // against the auto-reply principal before arming it, but a role edit after the
36
+ // fact can still make the runtime start denying the call — and the subscriber
37
+ // degrades to a no-op by design, because a broken model must never become a
38
+ // send. Without a persisted marker that leaves an "Auto-reply on" channel
39
+ // answering nothing with nothing for an operator to look at. Written only by
40
+ // `lib/channel-state-store.ts`, never by the subscriber directly.
41
+ aiAutoReplyLastError: z.string().optional(),
42
+ // When the *current* failure was first observed. A channel that keeps failing
43
+ // for the same reason does not rewrite the row on every inbound message, so
44
+ // this is a first-seen stamp rather than a last-attempt one — the alternative
45
+ // is a row update per message on exactly the channels already misbehaving.
46
+ aiAutoReplyLastErrorAt: z.string().optional()
47
+ }).partial().passthrough();
48
+ function parseDiscordCredentialsOrThrow(value) {
49
+ const parsed = discordCredentialsSchema.safeParse(value);
50
+ if (!parsed.success) {
51
+ const first = parsed.error.issues[0];
52
+ throw new Error(`[internal] Invalid Discord credentials: ${first?.message ?? "unknown validation error"}`);
53
+ }
54
+ return parsed.data;
55
+ }
56
+ export {
57
+ discordChannelStateSchema,
58
+ discordCredentialsSchema,
59
+ parseDiscordCredentialsOrThrow
60
+ };
61
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_discord/lib/credentials.ts"],
4
+ "sourcesContent": ["import { z } from 'zod'\n\n/**\n * Discord bot channel credentials (SPEC 2026-06-19 \u00A7 Data models).\n *\n * The hub persists this blob inside `IntegrationCredentials.credentials`\n * (encrypted at rest, scope `channel_discord`). Never log any value \u2014 the bot\n * token grants full control of the bot user.\n *\n * `.passthrough()` (not `.strict()`) so the connect-credential-channel command\n * can stash bookkeeping fields (e.g. `userId`) alongside the entered credentials,\n * mirroring the IMAP provider.\n */\nexport const discordCredentialsSchema = z\n .object({\n // \"Bot <token>\" \u2014 used as `Authorization: Bot <token>` on every REST call and\n // for the gateway Identify handshake. Never logged.\n botToken: z.string().min(1, 'Bot token required'),\n // Application (client) id \u2014 needed to register slash commands + build invites.\n applicationId: z.string().min(1, 'Application ID required'),\n // Ed25519 public key (hex) from the application's General Information tab \u2014\n // verifies signed interaction requests. Never used as a secret; safe to store.\n publicKey: z\n .string()\n .min(1, 'Public key required')\n .regex(/^[0-9a-fA-F]+$/, 'Public key must be hex')\n .refine((value) => value.length === 64, 'Public key must be a 32-byte (64 hex char) Ed25519 key'),\n // Scope the bot to one guild (recommended). Optional.\n guildId: z.string().optional(),\n // Default outbound text channel id (used by the test-send smoke test).\n defaultChannelId: z.string().optional(),\n })\n .passthrough()\n\nexport type DiscordCredentials = z.infer<typeof discordCredentialsSchema>\n\n/**\n * Gateway resume state persisted on `CommunicationChannel.channelState` (JSONB,\n * additive) so the worker can `RESUME` instead of re-`IDENTIFY` after a\n * disconnect. Discord requires the stored `resumeGatewayUrl` + `sessionId` +\n * last `sequence` to resume a session.\n */\nexport const discordChannelStateSchema = z\n .object({\n sessionId: z.string().optional(),\n sequence: z.number().nullable().optional(),\n resumeGatewayUrl: z.string().optional(),\n // Bot's own user id \u2014 cached after the READY event so the worker can drop\n // events it authored (feedback-loop guard) without an extra REST call.\n botUserId: z.string().optional(),\n lastConnectedAt: z.string().optional(),\n // Per-channel AI auto-reply toggle (default OFF). When truthy, the AI\n // auto-reply subscriber may answer \"easy\" inbound messages and proposes a\n // reply for everything else. Written by the channel's AI auto-reply settings\n // form via `PUT /api/channel_discord/channels/{id}/ai-auto-reply`.\n aiAutoReplyEnabled: z.boolean().optional(),\n // Object-mode agent id invoked when auto-reply is enabled. Defaults to the\n // provider's own `channel_discord.auto_reply` agent; a tenant may point it at\n // any object-mode agent whose `requiredFeatures` its channel-bot user holds.\n aiAgentId: z.string().optional(),\n // Why the last auto-reply attempt produced nothing, cleared by the next\n // attempt that gets somewhere. The settings route validates a channel's agent\n // against the auto-reply principal before arming it, but a role edit after the\n // fact can still make the runtime start denying the call \u2014 and the subscriber\n // degrades to a no-op by design, because a broken model must never become a\n // send. Without a persisted marker that leaves an \"Auto-reply on\" channel\n // answering nothing with nothing for an operator to look at. Written only by\n // `lib/channel-state-store.ts`, never by the subscriber directly.\n aiAutoReplyLastError: z.string().optional(),\n // When the *current* failure was first observed. A channel that keeps failing\n // for the same reason does not rewrite the row on every inbound message, so\n // this is a first-seen stamp rather than a last-attempt one \u2014 the alternative\n // is a row update per message on exactly the channels already misbehaving.\n aiAutoReplyLastErrorAt: z.string().optional(),\n })\n .partial()\n .passthrough()\n\nexport type DiscordChannelState = z.infer<typeof discordChannelStateSchema>\n\nexport function parseDiscordCredentialsOrThrow(value: unknown): DiscordCredentials {\n const parsed = discordCredentialsSchema.safeParse(value)\n if (!parsed.success) {\n const first = parsed.error.issues[0]\n throw new Error(`[internal] Invalid Discord credentials: ${first?.message ?? 'unknown validation error'}`)\n }\n return parsed.data\n}\n"],
5
+ "mappings": "AAAA,SAAS,SAAS;AAaX,MAAM,2BAA2B,EACrC,OAAO;AAAA;AAAA;AAAA,EAGN,UAAU,EAAE,OAAO,EAAE,IAAI,GAAG,oBAAoB;AAAA;AAAA,EAEhD,eAAe,EAAE,OAAO,EAAE,IAAI,GAAG,yBAAyB;AAAA;AAAA;AAAA,EAG1D,WAAW,EACR,OAAO,EACP,IAAI,GAAG,qBAAqB,EAC5B,MAAM,kBAAkB,wBAAwB,EAChD,OAAO,CAAC,UAAU,MAAM,WAAW,IAAI,wDAAwD;AAAA;AAAA,EAElG,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA,EAE7B,kBAAkB,EAAE,OAAO,EAAE,SAAS;AACxC,CAAC,EACA,YAAY;AAUR,MAAM,4BAA4B,EACtC,OAAO;AAAA,EACN,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,EAC/B,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACzC,kBAAkB,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA;AAAA,EAGtC,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,EAC/B,iBAAiB,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,EAKrC,oBAAoB,EAAE,QAAQ,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA,EAIzC,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAS/B,sBAAsB,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,EAK1C,wBAAwB,EAAE,OAAO,EAAE,SAAS;AAC9C,CAAC,EACA,QAAQ,EACR,YAAY;AAIR,SAAS,+BAA+B,OAAoC;AACjF,QAAM,SAAS,yBAAyB,UAAU,KAAK;AACvD,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,QAAQ,OAAO,MAAM,OAAO,CAAC;AACnC,UAAM,IAAI,MAAM,2CAA2C,OAAO,WAAW,0BAA0B,EAAE;AAAA,EAC3G;AACA,SAAO,OAAO;AAChB;",
6
+ "names": []
7
+ }
@@ -0,0 +1,289 @@
1
+ const GatewayOpcode = {
2
+ DISPATCH: 0,
3
+ HEARTBEAT: 1,
4
+ IDENTIFY: 2,
5
+ RESUME: 6,
6
+ RECONNECT: 7,
7
+ INVALID_SESSION: 9,
8
+ HELLO: 10,
9
+ HEARTBEAT_ACK: 11
10
+ };
11
+ const INTENT_GUILDS = 1 << 0;
12
+ const INTENT_GUILD_MESSAGES = 1 << 9;
13
+ const INTENT_GUILD_MESSAGE_REACTIONS = 1 << 10;
14
+ const INTENT_MESSAGE_CONTENT = 1 << 15;
15
+ const DISCORD_GATEWAY_INTENTS = INTENT_GUILDS | INTENT_GUILD_MESSAGES | INTENT_GUILD_MESSAGE_REACTIONS | INTENT_MESSAGE_CONTENT;
16
+ const DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
17
+ const FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([4004, 4010, 4011, 4012, 4013, 4014]);
18
+ function isFatalGatewayCloseCode(code) {
19
+ return typeof code === "number" && FATAL_CLOSE_CODES.has(code);
20
+ }
21
+ function shouldResumeAfterClose(code) {
22
+ if (code === void 0) return true;
23
+ if (isFatalGatewayCloseCode(code)) return false;
24
+ return code !== 4007 && code !== 4009 && code !== 1e3 && code !== 1001;
25
+ }
26
+ function createHeartbeatMonitor() {
27
+ let acked = true;
28
+ return {
29
+ onBeat() {
30
+ if (!acked) return "reconnect";
31
+ acked = false;
32
+ return "send";
33
+ },
34
+ onAck() {
35
+ acked = true;
36
+ },
37
+ reset() {
38
+ acked = true;
39
+ },
40
+ isAcked() {
41
+ return acked;
42
+ }
43
+ };
44
+ }
45
+ function computeReconnectDelayMs(attempt) {
46
+ const base = 1e3 * Math.pow(2, Math.min(attempt, 5));
47
+ const jitter = Math.floor(Math.random() * 500);
48
+ return Math.min(base + jitter, 3e4);
49
+ }
50
+ function buildIdentifyPayload(botToken, intents = DISCORD_GATEWAY_INTENTS) {
51
+ return {
52
+ op: GatewayOpcode.IDENTIFY,
53
+ d: {
54
+ token: botToken,
55
+ intents,
56
+ properties: { os: "linux", browser: "open-mercato", device: "open-mercato" }
57
+ }
58
+ };
59
+ }
60
+ function buildResumePayload(botToken, sessionId, seq) {
61
+ return {
62
+ op: GatewayOpcode.RESUME,
63
+ d: { token: botToken, session_id: sessionId, seq }
64
+ };
65
+ }
66
+ function buildHeartbeatPayload(seq) {
67
+ return { op: GatewayOpcode.HEARTBEAT, d: seq };
68
+ }
69
+ class NativeDiscordGatewayClient {
70
+ connect(options) {
71
+ const connection = new GatewaySession(options);
72
+ connection.open();
73
+ return {
74
+ close: () => connection.stop(),
75
+ isActive: () => connection.isRunning()
76
+ };
77
+ }
78
+ }
79
+ class GatewaySession {
80
+ constructor(options) {
81
+ this.options = options;
82
+ this.ws = null;
83
+ this.heartbeatTimer = null;
84
+ this.heartbeat = createHeartbeatMonitor();
85
+ this.sequence = null;
86
+ this.stopped = false;
87
+ this.attempt = 0;
88
+ this.sequence = options.resumeState?.sequence ?? null;
89
+ this.sessionId = options.resumeState?.sessionId;
90
+ this.resumeGatewayUrl = options.resumeState?.resumeGatewayUrl;
91
+ }
92
+ open() {
93
+ if (this.stopped) return;
94
+ const url = this.canResume() ? `${this.resumeGatewayUrl}?v=10&encoding=json` : DISCORD_GATEWAY_URL;
95
+ const factory = this.options.webSocketFactory ?? defaultWebSocketFactory;
96
+ const ws = factory(url);
97
+ this.ws = ws;
98
+ ws.addEventListener("message", (event) => this.onMessage(event));
99
+ ws.addEventListener("close", (event) => this.onClose(event));
100
+ ws.addEventListener("error", () => {
101
+ });
102
+ }
103
+ stop() {
104
+ this.stopped = true;
105
+ this.clearHeartbeat();
106
+ try {
107
+ this.ws?.close(1e3, "client stop");
108
+ } catch {
109
+ }
110
+ this.ws = null;
111
+ }
112
+ isRunning() {
113
+ return !this.stopped;
114
+ }
115
+ canResume() {
116
+ return Boolean(this.sessionId && this.resumeGatewayUrl);
117
+ }
118
+ /**
119
+ * Drop the resume state so the next connect performs a fresh `IDENTIFY`
120
+ * instead of a `RESUME` that Discord would reject again.
121
+ */
122
+ forgetSession() {
123
+ this.sessionId = void 0;
124
+ this.resumeGatewayUrl = void 0;
125
+ this.sequence = null;
126
+ this.options.onResumeStateChange?.({ sessionId: void 0, sequence: null, resumeGatewayUrl: void 0 });
127
+ }
128
+ onMessage(event) {
129
+ const data = event?.data;
130
+ if (typeof data !== "string") return;
131
+ let payload;
132
+ try {
133
+ payload = JSON.parse(data);
134
+ } catch {
135
+ return;
136
+ }
137
+ if (typeof payload.s === "number") {
138
+ this.sequence = payload.s;
139
+ this.options.onResumeStateChange?.({
140
+ sessionId: this.sessionId,
141
+ sequence: this.sequence,
142
+ resumeGatewayUrl: this.resumeGatewayUrl
143
+ });
144
+ }
145
+ switch (payload.op) {
146
+ case GatewayOpcode.HELLO: {
147
+ const interval = payload.d?.heartbeat_interval ?? 41250;
148
+ this.startHeartbeat(interval);
149
+ if (this.canResume()) {
150
+ this.sendJson(buildResumePayload(this.options.botToken, this.sessionId, this.sequence));
151
+ } else {
152
+ this.sendJson(buildIdentifyPayload(this.options.botToken, this.options.intents ?? DISCORD_GATEWAY_INTENTS));
153
+ }
154
+ break;
155
+ }
156
+ case GatewayOpcode.HEARTBEAT: {
157
+ this.sendJson(buildHeartbeatPayload(this.sequence));
158
+ break;
159
+ }
160
+ case GatewayOpcode.HEARTBEAT_ACK: {
161
+ this.heartbeat.onAck();
162
+ break;
163
+ }
164
+ case GatewayOpcode.INVALID_SESSION: {
165
+ if (payload.d !== true) this.forgetSession();
166
+ this.clearHeartbeat();
167
+ try {
168
+ this.ws?.close(4e3, "invalid session");
169
+ } catch {
170
+ }
171
+ break;
172
+ }
173
+ case GatewayOpcode.RECONNECT: {
174
+ try {
175
+ this.ws?.close(4e3, "server requested reconnect");
176
+ } catch {
177
+ }
178
+ break;
179
+ }
180
+ case GatewayOpcode.DISPATCH: {
181
+ this.attempt = 0;
182
+ void this.onDispatch(payload.t ?? "", payload.d);
183
+ break;
184
+ }
185
+ default:
186
+ break;
187
+ }
188
+ }
189
+ async onDispatch(type, data) {
190
+ if (type === "READY") {
191
+ const ready = data;
192
+ this.sessionId = ready.session_id;
193
+ this.resumeGatewayUrl = ready.resume_gateway_url;
194
+ const resumeState = {
195
+ sessionId: this.sessionId,
196
+ sequence: this.sequence,
197
+ resumeGatewayUrl: this.resumeGatewayUrl
198
+ };
199
+ this.options.onResumeStateChange?.(resumeState);
200
+ await this.options.onReady({ botUserId: ready.user?.id ?? "", resumeState });
201
+ return;
202
+ }
203
+ if (type === "MESSAGE_CREATE") {
204
+ await this.options.onMessage(data);
205
+ return;
206
+ }
207
+ if (type === "MESSAGE_REACTION_ADD") {
208
+ await this.options.onReaction(data, "added");
209
+ return;
210
+ }
211
+ if (type === "MESSAGE_REACTION_REMOVE") {
212
+ await this.options.onReaction(data, "removed");
213
+ return;
214
+ }
215
+ }
216
+ onClose(event) {
217
+ this.clearHeartbeat();
218
+ const code = event?.code;
219
+ if (isFatalGatewayCloseCode(code)) {
220
+ void this.options.onRequiresReauth({ code });
221
+ this.stopped = true;
222
+ return;
223
+ }
224
+ if (!shouldResumeAfterClose(code)) {
225
+ this.forgetSession();
226
+ }
227
+ if (this.stopped) return;
228
+ const delay = computeReconnectDelayMs(this.attempt);
229
+ this.attempt += 1;
230
+ setTimeout(() => this.open(), delay);
231
+ }
232
+ startHeartbeat(intervalMs) {
233
+ this.clearHeartbeat();
234
+ this.heartbeat.reset();
235
+ this.heartbeatTimer = setInterval(() => {
236
+ if (this.heartbeat.onBeat() === "reconnect") {
237
+ this.clearHeartbeat();
238
+ try {
239
+ this.ws?.close(4e3, "heartbeat ack timeout");
240
+ } catch {
241
+ }
242
+ return;
243
+ }
244
+ this.sendJson(buildHeartbeatPayload(this.sequence));
245
+ }, intervalMs);
246
+ }
247
+ clearHeartbeat() {
248
+ if (this.heartbeatTimer) {
249
+ clearInterval(this.heartbeatTimer);
250
+ this.heartbeatTimer = null;
251
+ }
252
+ }
253
+ sendJson(payload) {
254
+ try {
255
+ this.ws?.send(JSON.stringify(payload));
256
+ } catch {
257
+ }
258
+ }
259
+ }
260
+ function defaultWebSocketFactory(url) {
261
+ const globalWebSocket = globalThis.WebSocket;
262
+ if (!globalWebSocket) {
263
+ throw new Error("[internal] global WebSocket is unavailable \u2014 Node >= 22 is required for the Discord gateway worker");
264
+ }
265
+ return new globalWebSocket(url);
266
+ }
267
+ let cachedClient = null;
268
+ function getDiscordGatewayClient() {
269
+ if (!cachedClient) cachedClient = new NativeDiscordGatewayClient();
270
+ return cachedClient;
271
+ }
272
+ function setDiscordGatewayClient(client) {
273
+ cachedClient = client;
274
+ }
275
+ export {
276
+ DISCORD_GATEWAY_INTENTS,
277
+ DISCORD_GATEWAY_URL,
278
+ GatewayOpcode,
279
+ buildHeartbeatPayload,
280
+ buildIdentifyPayload,
281
+ buildResumePayload,
282
+ computeReconnectDelayMs,
283
+ createHeartbeatMonitor,
284
+ getDiscordGatewayClient,
285
+ isFatalGatewayCloseCode,
286
+ setDiscordGatewayClient,
287
+ shouldResumeAfterClose
288
+ };
289
+ //# sourceMappingURL=discord-gateway-client.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_discord/lib/discord-gateway-client.ts"],
4
+ "sourcesContent": ["import type { DiscordMessageObject } from './discord-rest'\n\n/**\n * Discord Gateway (WebSocket) client. Uses the Node >= 22 built-in global\n * `WebSocket` \u2014 no `ws` dependency. The network-touching class only opens a\n * socket when `connect()` is called, so importing this module has no side\n * effects; the state-machine helpers below are pure and unit-tested.\n *\n * Swappable in tests / alternate transports via `setDiscordGatewayClient(...)`.\n */\n\n// \u2500\u2500 Gateway opcodes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport const GatewayOpcode = {\n DISPATCH: 0,\n HEARTBEAT: 1,\n IDENTIFY: 2,\n RESUME: 6,\n RECONNECT: 7,\n INVALID_SESSION: 9,\n HELLO: 10,\n HEARTBEAT_ACK: 11,\n} as const\n\n// \u2500\u2500 Gateway intents (bitfield) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst INTENT_GUILDS = 1 << 0\nconst INTENT_GUILD_MESSAGES = 1 << 9\nconst INTENT_GUILD_MESSAGE_REACTIONS = 1 << 10\nconst INTENT_MESSAGE_CONTENT = 1 << 15\n\n/**\n * Default intents the bot identifies with: guild lifecycle, guild message\n * create/reactions, and (privileged) message content so inbound text is\n * readable. `DIRECT_MESSAGES` is intentionally omitted for the first release.\n */\nexport const DISCORD_GATEWAY_INTENTS =\n INTENT_GUILDS | INTENT_GUILD_MESSAGES | INTENT_GUILD_MESSAGE_REACTIONS | INTENT_MESSAGE_CONTENT\n\nexport const DISCORD_GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json'\n\n/**\n * Fatal close codes: the bot token or intents are wrong and reconnecting will\n * not help. The worker surfaces these as `requires_reauth` instead of looping.\n * 4004 Authentication failed, 4014 Disallowed intents.\n */\nconst FATAL_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014])\n\nexport function isFatalGatewayCloseCode(code: number | undefined): boolean {\n return typeof code === 'number' && FATAL_CLOSE_CODES.has(code)\n}\n\n/**\n * Whether a close code invalidates the current session so the next connect must\n * re-`IDENTIFY` (fresh session) rather than `RESUME`. 4007 (invalid seq) and\n * 4009 (session timed out) require a fresh identify.\n */\nexport function shouldResumeAfterClose(code: number | undefined): boolean {\n if (code === undefined) return true\n if (isFatalGatewayCloseCode(code)) return false\n return code !== 4007 && code !== 4009 && code !== 1000 && code !== 1001\n}\n\n/**\n * Heartbeat/ACK zombie detector. Discord expects a Heartbeat ACK (opcode 11)\n * after every heartbeat we send; a missing ACK before the next heartbeat means\n * the connection is a zombie (TCP up, gateway dead) and MUST be torn down +\n * reconnected rather than left silently deaf. Pure + unit-testable.\n *\n * Contract: call `onBeat()` on each heartbeat-interval tick \u2014 it returns\n * `'reconnect'` when the previous beat was never acked, else `'send'` (and marks\n * the new beat pending). Call `onAck()` on opcode 11. `reset()` re-arms after a\n * fresh (re)connect.\n */\nexport function createHeartbeatMonitor() {\n let acked = true\n return {\n onBeat(): 'send' | 'reconnect' {\n if (!acked) return 'reconnect'\n acked = false\n return 'send'\n },\n onAck(): void {\n acked = true\n },\n reset(): void {\n acked = true\n },\n isAcked(): boolean {\n return acked\n },\n }\n}\n\nexport type HeartbeatMonitor = ReturnType<typeof createHeartbeatMonitor>\n\n/** Exponential backoff with jitter, capped, for reconnect attempts. */\nexport function computeReconnectDelayMs(attempt: number): number {\n const base = 1000 * Math.pow(2, Math.min(attempt, 5))\n const jitter = Math.floor(Math.random() * 500)\n return Math.min(base + jitter, 30_000)\n}\n\nexport interface IdentifyPayload {\n op: number\n d: {\n token: string\n intents: number\n properties: { os: string; browser: string; device: string }\n }\n}\n\nexport function buildIdentifyPayload(botToken: string, intents: number = DISCORD_GATEWAY_INTENTS): IdentifyPayload {\n return {\n op: GatewayOpcode.IDENTIFY,\n d: {\n token: botToken,\n intents,\n properties: { os: 'linux', browser: 'open-mercato', device: 'open-mercato' },\n },\n }\n}\n\nexport interface ResumePayload {\n op: number\n d: { token: string; session_id: string; seq: number | null }\n}\n\nexport function buildResumePayload(botToken: string, sessionId: string, seq: number | null): ResumePayload {\n return {\n op: GatewayOpcode.RESUME,\n d: { token: botToken, session_id: sessionId, seq },\n }\n}\n\nexport function buildHeartbeatPayload(seq: number | null): { op: number; d: number | null } {\n return { op: GatewayOpcode.HEARTBEAT, d: seq }\n}\n\n// \u2500\u2500 Connection interface \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface GatewayResumeState {\n sessionId?: string\n sequence?: number | null\n resumeGatewayUrl?: string\n}\n\nexport interface DiscordGatewayConnectOptions {\n botToken: string\n intents?: number\n resumeState?: GatewayResumeState\n /** Called for each `MESSAGE_CREATE` dispatch. */\n onMessage: (message: DiscordMessageObject) => void | Promise<void>\n /** Called for each `MESSAGE_REACTION_ADD` / `_REMOVE` dispatch. */\n onReaction: (reaction: Record<string, unknown>, action: 'added' | 'removed') => void | Promise<void>\n /** Called on READY with the bot's own user id + fresh resume state. */\n onReady: (info: { botUserId: string; resumeState: GatewayResumeState }) => void | Promise<void>\n /** Called on a fatal auth/intents close code (4004/4014). */\n onRequiresReauth: (info: { code: number }) => void | Promise<void>\n /** Optional persisted resume-state updates (session id + sequence). */\n onResumeStateChange?: (resumeState: GatewayResumeState) => void\n /** Injected WebSocket factory for tests; defaults to global `WebSocket`. */\n webSocketFactory?: (url: string) => GatewayWebSocketLike\n}\n\nexport interface GatewayWebSocketLike {\n send(data: string): void\n close(code?: number, reason?: string): void\n addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: unknown) => void): void\n}\n\nexport interface DiscordGatewayHandle {\n /** Close the socket and stop reconnecting. */\n close(): void\n /**\n * Whether this handle still owns a running session. A session that is merely\n * between sockets (reconnect backoff after a resumable close) is still active \u2014\n * it heals itself. It reports inactive only after `close()` or a fatal close\n * code, which is exactly when the gateway worker must open a new one.\n */\n isActive(): boolean\n}\n\nexport interface DiscordGatewayClient {\n connect(options: DiscordGatewayConnectOptions): DiscordGatewayHandle\n}\n\n/**\n * Native-WebSocket-backed gateway connection. Handles the HELLO\u2192IDENTIFY/RESUME\n * handshake, heartbeat with sequence tracking, dispatch fan-out, and\n * reconnect-with-backoff. One instance owns exactly one socket.\n */\nclass NativeDiscordGatewayClient implements DiscordGatewayClient {\n connect(options: DiscordGatewayConnectOptions): DiscordGatewayHandle {\n const connection = new GatewaySession(options)\n connection.open()\n return {\n close: () => connection.stop(),\n isActive: () => connection.isRunning(),\n }\n }\n}\n\nclass GatewaySession {\n private ws: GatewayWebSocketLike | null = null\n private heartbeatTimer: ReturnType<typeof setInterval> | null = null\n private readonly heartbeat = createHeartbeatMonitor()\n private sequence: number | null = null\n private sessionId: string | undefined\n private resumeGatewayUrl: string | undefined\n private stopped = false\n private attempt = 0\n\n constructor(private readonly options: DiscordGatewayConnectOptions) {\n this.sequence = options.resumeState?.sequence ?? null\n this.sessionId = options.resumeState?.sessionId\n this.resumeGatewayUrl = options.resumeState?.resumeGatewayUrl\n }\n\n open(): void {\n if (this.stopped) return\n const url = this.canResume() ? `${this.resumeGatewayUrl}?v=10&encoding=json` : DISCORD_GATEWAY_URL\n const factory = this.options.webSocketFactory ?? defaultWebSocketFactory\n const ws = factory(url)\n this.ws = ws\n ws.addEventListener('message', (event) => this.onMessage(event))\n ws.addEventListener('close', (event) => this.onClose(event))\n ws.addEventListener('error', () => {\n /* the close handler drives reconnect; errors are logged by the worker */\n })\n }\n\n stop(): void {\n this.stopped = true\n this.clearHeartbeat()\n try {\n this.ws?.close(1000, 'client stop')\n } catch {\n /* ignore */\n }\n this.ws = null\n }\n\n isRunning(): boolean {\n return !this.stopped\n }\n\n private canResume(): boolean {\n return Boolean(this.sessionId && this.resumeGatewayUrl)\n }\n\n /**\n * Drop the resume state so the next connect performs a fresh `IDENTIFY`\n * instead of a `RESUME` that Discord would reject again.\n */\n private forgetSession(): void {\n this.sessionId = undefined\n this.resumeGatewayUrl = undefined\n this.sequence = null\n this.options.onResumeStateChange?.({ sessionId: undefined, sequence: null, resumeGatewayUrl: undefined })\n }\n\n private onMessage(event: unknown): void {\n const data = (event as { data?: unknown })?.data\n if (typeof data !== 'string') return\n let payload: { op: number; d?: unknown; s?: number | null; t?: string | null }\n try {\n payload = JSON.parse(data)\n } catch {\n return\n }\n if (typeof payload.s === 'number') {\n this.sequence = payload.s\n this.options.onResumeStateChange?.({\n sessionId: this.sessionId,\n sequence: this.sequence,\n resumeGatewayUrl: this.resumeGatewayUrl,\n })\n }\n\n switch (payload.op) {\n case GatewayOpcode.HELLO: {\n const interval = (payload.d as { heartbeat_interval?: number } | undefined)?.heartbeat_interval ?? 41250\n this.startHeartbeat(interval)\n if (this.canResume()) {\n this.sendJson(buildResumePayload(this.options.botToken, this.sessionId as string, this.sequence))\n } else {\n this.sendJson(buildIdentifyPayload(this.options.botToken, this.options.intents ?? DISCORD_GATEWAY_INTENTS))\n }\n break\n }\n case GatewayOpcode.HEARTBEAT: {\n // Server-requested immediate heartbeat.\n this.sendJson(buildHeartbeatPayload(this.sequence))\n break\n }\n case GatewayOpcode.HEARTBEAT_ACK: {\n this.heartbeat.onAck()\n break\n }\n case GatewayOpcode.INVALID_SESSION: {\n // `d` is Discord's `resumable` flag. When it is `false` the session is\n // dead and the next connect MUST re-`IDENTIFY`; when it is `true` the\n // stored session survives and the next connect may `RESUME`.\n //\n // Either way Discord stops serving this socket, so we have to close it\n // and let `onClose` run the shared backoff + handshake path. Only\n // clearing the resume state (the previous behaviour) left the socket\n // open but permanently deaf: no dispatches, no reconnect, no error.\n if (payload.d !== true) this.forgetSession()\n this.clearHeartbeat()\n try {\n this.ws?.close(4000, 'invalid session')\n } catch {\n /* the close handler reconnects */\n }\n break\n }\n case GatewayOpcode.RECONNECT: {\n try {\n this.ws?.close(4000, 'server requested reconnect')\n } catch {\n /* the close handler reconnects */\n }\n break\n }\n case GatewayOpcode.DISPATCH: {\n this.attempt = 0\n void this.onDispatch(payload.t ?? '', payload.d)\n break\n }\n default:\n break\n }\n }\n\n private async onDispatch(type: string, data: unknown): Promise<void> {\n if (type === 'READY') {\n const ready = data as { session_id?: string; resume_gateway_url?: string; user?: { id?: string } }\n this.sessionId = ready.session_id\n this.resumeGatewayUrl = ready.resume_gateway_url\n const resumeState: GatewayResumeState = {\n sessionId: this.sessionId,\n sequence: this.sequence,\n resumeGatewayUrl: this.resumeGatewayUrl,\n }\n this.options.onResumeStateChange?.(resumeState)\n await this.options.onReady({ botUserId: ready.user?.id ?? '', resumeState })\n return\n }\n if (type === 'MESSAGE_CREATE') {\n await this.options.onMessage(data as DiscordMessageObject)\n return\n }\n if (type === 'MESSAGE_REACTION_ADD') {\n await this.options.onReaction(data as Record<string, unknown>, 'added')\n return\n }\n if (type === 'MESSAGE_REACTION_REMOVE') {\n await this.options.onReaction(data as Record<string, unknown>, 'removed')\n return\n }\n }\n\n private onClose(event: unknown): void {\n this.clearHeartbeat()\n const code = (event as { code?: number })?.code\n if (isFatalGatewayCloseCode(code)) {\n void this.options.onRequiresReauth({ code: code as number })\n this.stopped = true\n return\n }\n if (!shouldResumeAfterClose(code)) {\n this.forgetSession()\n }\n if (this.stopped) return\n const delay = computeReconnectDelayMs(this.attempt)\n this.attempt += 1\n setTimeout(() => this.open(), delay)\n }\n\n private startHeartbeat(intervalMs: number): void {\n this.clearHeartbeat()\n this.heartbeat.reset()\n this.heartbeatTimer = setInterval(() => {\n if (this.heartbeat.onBeat() === 'reconnect') {\n // Previous heartbeat was never ACKed \u2192 zombie connection. Force a close\n // so the close handler reconnects (with resume) instead of staying deaf.\n this.clearHeartbeat()\n try {\n this.ws?.close(4000, 'heartbeat ack timeout')\n } catch {\n /* the close handler reconnects */\n }\n return\n }\n this.sendJson(buildHeartbeatPayload(this.sequence))\n }, intervalMs)\n }\n\n private clearHeartbeat(): void {\n if (this.heartbeatTimer) {\n clearInterval(this.heartbeatTimer)\n this.heartbeatTimer = null\n }\n }\n\n private sendJson(payload: unknown): void {\n try {\n this.ws?.send(JSON.stringify(payload))\n } catch {\n /* the close handler drives reconnect */\n }\n }\n}\n\nfunction defaultWebSocketFactory(url: string): GatewayWebSocketLike {\n const globalWebSocket = (globalThis as { WebSocket?: new (url: string) => GatewayWebSocketLike }).WebSocket\n if (!globalWebSocket) {\n throw new Error('[internal] global WebSocket is unavailable \u2014 Node >= 22 is required for the Discord gateway worker')\n }\n return new globalWebSocket(url)\n}\n\nlet cachedClient: DiscordGatewayClient | null = null\n\nexport function getDiscordGatewayClient(): DiscordGatewayClient {\n if (!cachedClient) cachedClient = new NativeDiscordGatewayClient()\n return cachedClient\n}\n\nexport function setDiscordGatewayClient(client: DiscordGatewayClient | null): void {\n cachedClient = client\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }