@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,152 @@
1
+ import { COMMUNICATION_CHANNELS_QUEUES } from '@open-mercato/core/modules/communication_channels/lib/queue'
2
+ import { setDiscordRestClient, type DiscordRestClient } from '../../lib/discord-rest'
3
+ import type { DispatchableInteraction } from '../../lib/interactions-dispatch'
4
+ import type { InteractionDispatchJobPayload } from '../../lib/interactions-queue'
5
+ import { DiscordInteractionType } from '../../lib/interactions-verify'
6
+ import handle from '../discord-interactions'
7
+
8
+ const enqueue = jest.fn(async () => {})
9
+
10
+ jest.mock('@open-mercato/core/modules/communication_channels/lib/queue', () => {
11
+ const actual = jest.requireActual('@open-mercato/core/modules/communication_channels/lib/queue')
12
+ return { ...actual, getCommunicationChannelsQueue: () => ({ enqueue }) }
13
+ })
14
+
15
+ const interaction: DispatchableInteraction = {
16
+ id: 'interaction-1',
17
+ token: 'interaction-token',
18
+ type: DiscordInteractionType.APPLICATION_COMMAND,
19
+ applicationId: 'app-1',
20
+ discordChannelId: 'discord-channel-1',
21
+ guildId: 'guild-1',
22
+ user: { id: 'user-1', username: 'ada' },
23
+ commandName: 'mercato',
24
+ content: '/mercato message:the printer is jammed',
25
+ timestamp: '2026-06-19T10:00:00.000Z',
26
+ }
27
+
28
+ function payload(overrides: Partial<InteractionDispatchJobPayload> = {}): InteractionDispatchJobPayload {
29
+ return {
30
+ channelId: 'ch-1',
31
+ channelType: 'discord',
32
+ tenantId: 't-1',
33
+ organizationId: 'o-1',
34
+ credentialScope: { tenantId: 't-1', organizationId: 'o-1', userId: null },
35
+ interaction,
36
+ ...overrides,
37
+ }
38
+ }
39
+
40
+ const credentials = {
41
+ botToken: 'bot-token',
42
+ applicationId: 'app-1',
43
+ publicKey: 'ab'.repeat(32),
44
+ }
45
+
46
+ function context(resolved: Record<string, unknown> | null = credentials) {
47
+ const resolve = jest.fn(async () => resolved)
48
+ return {
49
+ resolve: <T = unknown>(name: string): T => {
50
+ if (name === 'integrationCredentialsService') return { resolve } as unknown as T
51
+ throw new Error(`[internal] unexpected DI key ${name}`)
52
+ },
53
+ credentialsResolve: resolve,
54
+ }
55
+ }
56
+
57
+ function restClient(overrides: Partial<DiscordRestClient> = {}) {
58
+ const client = {
59
+ editOriginalInteractionResponse: jest.fn(async () => {}),
60
+ createInteractionFollowUp: jest.fn(async () => {}),
61
+ ...overrides,
62
+ }
63
+ setDiscordRestClient(client as unknown as DiscordRestClient)
64
+ return client
65
+ }
66
+
67
+ afterEach(() => {
68
+ enqueue.mockClear()
69
+ setDiscordRestClient(null)
70
+ })
71
+
72
+ describe('discord interactions worker', () => {
73
+ it('normalizes the interaction into the hub inbound queue and replaces the deferred ack', async () => {
74
+ const client = restClient()
75
+ const ctx = context()
76
+
77
+ await handle({ payload: payload() } as never, ctx as never)
78
+
79
+ expect(enqueue).toHaveBeenCalledTimes(1)
80
+ expect(enqueue).toHaveBeenCalledWith(
81
+ expect.objectContaining({
82
+ providerKey: 'discord',
83
+ channelId: 'ch-1',
84
+ channelType: 'discord',
85
+ scope: { tenantId: 't-1', organizationId: 'o-1' },
86
+ }),
87
+ )
88
+ expect(COMMUNICATION_CHANNELS_QUEUES.inbound).toBe('communication-channels-inbound')
89
+
90
+ expect(client.editOriginalInteractionResponse).toHaveBeenCalledTimes(1)
91
+ const [applicationId, token, body] = client.editOriginalInteractionResponse.mock.calls[0] as unknown as [
92
+ string,
93
+ string,
94
+ { content: string; ephemeral?: boolean },
95
+ ]
96
+ expect(applicationId).toBe('app-1')
97
+ expect(token).toBe('interaction-token')
98
+ expect(body.ephemeral).toBe(true)
99
+ expect(body.content).toContain('/mercato')
100
+ })
101
+
102
+ it('re-resolves the bot token from the credential store — the payload never carries one', async () => {
103
+ restClient()
104
+ const ctx = context()
105
+
106
+ await handle({ payload: payload() } as never, ctx as never)
107
+
108
+ expect(ctx.credentialsResolve).toHaveBeenCalledWith('channel_discord', {
109
+ tenantId: 't-1',
110
+ organizationId: 'o-1',
111
+ userId: null,
112
+ })
113
+ expect(JSON.stringify(payload())).not.toContain('bot-token')
114
+ })
115
+
116
+ it('still records the interaction in the hub when no credentials can answer it', async () => {
117
+ const client = restClient()
118
+ const ctx = context(null)
119
+
120
+ await handle({ payload: payload() } as never, ctx as never)
121
+
122
+ expect(enqueue).toHaveBeenCalledTimes(1)
123
+ expect(client.editOriginalInteractionResponse).not.toHaveBeenCalled()
124
+ })
125
+
126
+ it('propagates a follow-up failure so the queue retries inside the token lifetime', async () => {
127
+ restClient({
128
+ editOriginalInteractionResponse: jest.fn(async () => {
129
+ throw new Error('discord 500')
130
+ }),
131
+ createInteractionFollowUp: jest.fn(async () => {
132
+ throw new Error('discord 500')
133
+ }),
134
+ })
135
+
136
+ await expect(handle({ payload: payload() } as never, context() as never)).rejects.toThrow('discord 500')
137
+ })
138
+
139
+ it('drops a bot-invoked interaction from the hub but still answers it', async () => {
140
+ const client = restClient()
141
+
142
+ await handle(
143
+ {
144
+ payload: payload({ interaction: { ...interaction, user: { id: 'bot-1', username: 'bot', bot: true } } }),
145
+ } as never,
146
+ context() as never,
147
+ )
148
+
149
+ expect(enqueue).not.toHaveBeenCalled()
150
+ expect(client.editOriginalInteractionResponse).toHaveBeenCalledTimes(1)
151
+ })
152
+ })
@@ -0,0 +1,458 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { QueuedJob, WorkerMeta } from '@open-mercato/queue'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
6
+ import { createLogger } from '@open-mercato/shared/lib/logger'
7
+ import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
8
+ import {
9
+ COMMUNICATION_CHANNELS_QUEUES,
10
+ getCommunicationChannelsQueue,
11
+ } from '@open-mercato/core/modules/communication_channels/lib/queue'
12
+ import { emitCommunicationChannelsEvent } from '@open-mercato/core/modules/communication_channels/events'
13
+ import { parseDiscordCredentialsOrThrow, discordChannelStateSchema } from '../lib/credentials'
14
+ import {
15
+ getDiscordGatewayClient,
16
+ type DiscordGatewayHandle,
17
+ type GatewayResumeState,
18
+ } from '../lib/discord-gateway-client'
19
+ import {
20
+ persistDiscordChannelState,
21
+ quarantineDiscordChannel,
22
+ type DiscordChannelScope,
23
+ type DiscordChannelStatePatch,
24
+ } from '../lib/channel-state-store'
25
+ import { buildInboundMessageJob, buildReactionJob, type GatewayChannelScope } from '../lib/gateway-bridge'
26
+
27
+ const logger = createLogger('channel_discord').child({ component: 'gateway-worker' })
28
+
29
+ /**
30
+ * Long-running Discord Gateway bridge worker (SPEC 2026-06-19 § Gateway worker).
31
+ *
32
+ * This is a *provider-owned* long-running worker — a novel pattern relative to
33
+ * the email providers (which are poll/push-driven by the hub). A worker file
34
+ * under `workers/` is auto-discovered, so shipping one from a provider package is
35
+ * allowed by the framework. `concurrency: 1` enforces the single-identify-per-bot
36
+ * discipline Discord requires.
37
+ *
38
+ * The job opens one Gateway WebSocket per active `discord` channel and bridges
39
+ * `MESSAGE_CREATE` / reaction events into the hub's existing queues (inbound +
40
+ * reactions) — the same jobs the webhook route enqueues, so the hub stays
41
+ * unchanged. Events the bot authored are dropped (feedback-loop guard); the hub
42
+ * dedups the rest.
43
+ *
44
+ * Set `OM_CHANNEL_DISCORD_GATEWAY_DISABLED=1` to skip opening sockets (CI /
45
+ * send-only deployments).
46
+ */
47
+ /** Queue that the gateway bridge consumes. Shared with `cli.ts` (start-gateway). */
48
+ export const CHANNEL_DISCORD_GATEWAY_QUEUE = 'channel_discord_gateway'
49
+
50
+ export const metadata: WorkerMeta = {
51
+ queue: CHANNEL_DISCORD_GATEWAY_QUEUE,
52
+ id: 'channel_discord:gateway',
53
+ concurrency: 1,
54
+ }
55
+
56
+ type HandlerContext = {
57
+ resolve: <T = unknown>(name: string) => T
58
+ }
59
+
60
+ type GatewayJobPayload = {
61
+ /** Optional tenant filter; when absent, all active discord channels connect. */
62
+ tenantId?: string
63
+ /**
64
+ * Optional organization filter, narrowing within `tenantId`. Both the channel
65
+ * query and the reconciliation honour it, so a job scoped to one organization
66
+ * never opens, and never tears down, a socket belonging to another.
67
+ */
68
+ organizationId?: string | null
69
+ }
70
+
71
+ export interface GatewayConnectionEntry {
72
+ handle: DiscordGatewayHandle
73
+ tenantId: string
74
+ /** Owning organization, or `null` for a tenant-wide channel. Scopes teardown. */
75
+ organizationId: string | null
76
+ /** SHA-256 of the bot token, never the token itself — see `botTokenFingerprint`. */
77
+ botTokenFingerprint?: string
78
+ }
79
+
80
+ /**
81
+ * Stable, non-reversible identity for a bot token, used to detect that two
82
+ * channel rows are really the same Discord bot.
83
+ *
84
+ * The hub derives a channel's `externalIdentifier` by sniffing the credential
85
+ * bag for email-shaped keys, which Discord has none of, so every reconnect
86
+ * inserts a fresh row instead of healing the existing one. Until that is fixed
87
+ * hub-side, two rows for one bot would each open a socket and IDENTIFY
88
+ * independently, defeating the single-identify discipline `concurrency: 1`
89
+ * exists to enforce. Hashing keeps the token out of the registry and the logs.
90
+ */
91
+ export function botTokenFingerprint(botToken: string): string {
92
+ return createHash('sha256').update(botToken).digest('hex')
93
+ }
94
+
95
+ /**
96
+ * Whether another channel already holds a live session for the same bot.
97
+ * Pure over its arguments so the guard is unit-testable without sockets.
98
+ */
99
+ export function findChannelWithSameBot(
100
+ channelId: string,
101
+ fingerprint: string,
102
+ connections: Map<string, GatewayConnectionEntry> = activeConnections,
103
+ ): string | null {
104
+ for (const [otherId, entry] of connections) {
105
+ if (otherId === channelId) continue
106
+ if (entry.botTokenFingerprint !== fingerprint) continue
107
+ if (!isConnectionLive(entry)) continue
108
+ return otherId
109
+ }
110
+ return null
111
+ }
112
+
113
+ // Module-level registry so a re-run replaces an existing connection instead of
114
+ // opening a second socket for the same channel (single-identify discipline).
115
+ // Keyed by channel id; carries tenantId so a per-tenant reconciliation never
116
+ // tears down another tenant's sockets.
117
+ const activeConnections = new Map<string, GatewayConnectionEntry>()
118
+
119
+ // Serializes resume-state writes per channel. The socket callbacks that produce
120
+ // them (READY on every (re)connect) fire independently of the job loop, so
121
+ // without a chain two callbacks for the same channel could interleave their
122
+ // read-modify-write and lose the fresher one.
123
+ const pendingStateWrites = new Map<string, Promise<unknown>>()
124
+
125
+ /**
126
+ * Whether a registry entry still holds a running session. A session that is
127
+ * merely reconnecting with backoff after a resumable close reports `true` — it
128
+ * heals itself and MUST NOT be torn down and replaced by the refresh job.
129
+ */
130
+ export function isConnectionLive(entry: GatewayConnectionEntry | undefined): boolean {
131
+ if (!entry) return false
132
+ try {
133
+ return entry.handle.isActive()
134
+ } catch {
135
+ return false
136
+ }
137
+ }
138
+
139
+ /** The scope a reconciliation run is allowed to tear connections down within. */
140
+ export interface GatewayReconcileScope {
141
+ tenantId?: string
142
+ organizationId?: string | null
143
+ }
144
+
145
+ /**
146
+ * The channel query for one job payload. Every scope key the payload carries
147
+ * narrows the query: a job scoped to an organization must connect that
148
+ * organization's channels and no others, and the same scope is then handed to
149
+ * the reconciliation so it tears down exactly the set it queried.
150
+ */
151
+ export function buildGatewayChannelFilter(scope: GatewayReconcileScope): Record<string, unknown> {
152
+ const filter: Record<string, unknown> = { providerKey: 'discord', isActive: true, deletedAt: null }
153
+ if (scope.tenantId) filter.tenantId = scope.tenantId
154
+ if (scope.organizationId != null) filter.organizationId = scope.organizationId
155
+ return filter
156
+ }
157
+
158
+ /**
159
+ * Close + drop any live connection whose channel is no longer in the active set
160
+ * (deactivated / soft-deleted / re-scoped). Without this the socket + heartbeat
161
+ * timer would leak forever after a channel is disconnected. When `scope` names a
162
+ * tenant and/or an organization, only connections inside that scope are eligible
163
+ * for teardown — a scoped refresh reconciles exactly the set it queried and
164
+ * never touches another tenant's or organization's sockets. Returns the ids
165
+ * reconciled away. Pure over its arguments so it is unit-testable.
166
+ */
167
+ export function reconcileGatewayConnections(
168
+ activeChannelIds: Set<string>,
169
+ connections: Map<string, GatewayConnectionEntry> = activeConnections,
170
+ scope: GatewayReconcileScope = {},
171
+ ): string[] {
172
+ const removed: string[] = []
173
+ for (const [channelId, entry] of connections) {
174
+ if (activeChannelIds.has(channelId)) continue
175
+ if (scope.tenantId && entry.tenantId !== scope.tenantId) continue
176
+ if (scope.organizationId != null && entry.organizationId !== scope.organizationId) continue
177
+ try {
178
+ entry.handle.close()
179
+ } catch {
180
+ /* best-effort close */
181
+ }
182
+ connections.delete(channelId)
183
+ removed.push(channelId)
184
+ }
185
+ return removed
186
+ }
187
+
188
+ type CredentialsServiceLike = {
189
+ resolve: (
190
+ integrationId: string,
191
+ scope: { organizationId: string; tenantId: string; userId?: string | null },
192
+ ) => Promise<Record<string, unknown> | null>
193
+ }
194
+
195
+ export default async function handle(job: QueuedJob<GatewayJobPayload>, ctx: HandlerContext): Promise<void> {
196
+ if (parseBooleanWithDefault(process.env.OM_CHANNEL_DISCORD_GATEWAY_DISABLED, false)) {
197
+ logger.info('gateway disabled via OM_CHANNEL_DISCORD_GATEWAY_DISABLED — skipping connect')
198
+ return
199
+ }
200
+
201
+ const em = (ctx.resolve('em') as EntityManager).fork()
202
+ let credentialsService: CredentialsServiceLike | null = null
203
+ try {
204
+ credentialsService = ctx.resolve<CredentialsServiceLike>('integrationCredentialsService')
205
+ } catch {
206
+ credentialsService = null
207
+ }
208
+ if (!credentialsService) {
209
+ logger.warn('integrationCredentialsService unavailable — cannot resolve bot tokens')
210
+ return
211
+ }
212
+
213
+ // A scoped job narrows the query by every scope key it carries — a payload
214
+ // that names an organization must not silently connect the whole tenant.
215
+ const scope: GatewayReconcileScope = {
216
+ tenantId: job.payload?.tenantId,
217
+ organizationId: job.payload?.organizationId ?? null,
218
+ }
219
+
220
+ const channels = (await findWithDecryption(
221
+ em,
222
+ CommunicationChannel,
223
+ buildGatewayChannelFilter(scope),
224
+ )) as CommunicationChannel[]
225
+
226
+ // The refresh job is a *reconciler*, not a re-connector: a channel whose
227
+ // session is still running is left alone. Restarting it (the previous
228
+ // behaviour) tore down healthy sockets on every tick of the CLI's 60s refresh
229
+ // and forced a fresh IDENTIFY each time, which burns Discord's session-start
230
+ // budget and drops the dispatches in flight.
231
+ let started = 0
232
+ let kept = 0
233
+ let quarantined = 0
234
+ for (const channel of channels) {
235
+ if (isConnectionLive(activeConnections.get(channel.id))) {
236
+ kept += 1
237
+ continue
238
+ }
239
+ // A channel Discord fatally rejected (bad token / disallowed intents) stays
240
+ // parked until an operator reconnects it. Retrying cannot fix either cause,
241
+ // and re-IDENTIFYing every tick burns the bot's daily session-start budget.
242
+ if (channel.status === 'requires_reauth') {
243
+ quarantined += 1
244
+ continue
245
+ }
246
+ await startChannelConnection(channel, credentialsService, em)
247
+ started += 1
248
+ }
249
+
250
+ // Full reconciliation: close sockets for channels that dropped out of the
251
+ // active set since the last run (deactivated / soft-deleted). A scoped run
252
+ // reconciles within exactly the scope it queried, so a per-tenant or
253
+ // per-organization refresh never tears down sockets it did not consider.
254
+ const activeIds = new Set(channels.map((channel) => channel.id))
255
+ const removed = reconcileGatewayConnections(activeIds, activeConnections, scope)
256
+ if (removed.length > 0) {
257
+ logger.info('reconciled away stale discord gateway connections', { channelIds: removed })
258
+ }
259
+ logger.debug('discord gateway reconciliation finished', { started, kept, quarantined, removed: removed.length })
260
+ }
261
+
262
+ async function startChannelConnection(
263
+ channel: CommunicationChannel,
264
+ credentialsService: CredentialsServiceLike,
265
+ em: EntityManager,
266
+ ): Promise<void> {
267
+ const scope: GatewayChannelScope = {
268
+ channelId: channel.id,
269
+ channelType: channel.channelType,
270
+ tenantId: channel.tenantId,
271
+ organizationId: channel.organizationId ?? null,
272
+ }
273
+
274
+ let credentials: Record<string, unknown> | null = null
275
+ try {
276
+ credentials = await credentialsService.resolve('channel_discord', {
277
+ tenantId: channel.tenantId,
278
+ organizationId: channel.organizationId ?? channel.tenantId,
279
+ userId: channel.userId ?? null,
280
+ })
281
+ } catch (err) {
282
+ logger.warn('failed to resolve discord credentials for channel', { channelId: channel.id, err })
283
+ return
284
+ }
285
+ if (!credentials) return
286
+
287
+ let botToken: string
288
+ try {
289
+ botToken = parseDiscordCredentialsOrThrow(credentials).botToken
290
+ } catch (err) {
291
+ logger.warn('invalid discord credentials for channel', { channelId: channel.id, err })
292
+ return
293
+ }
294
+
295
+ const fingerprint = botTokenFingerprint(botToken)
296
+ const duplicateOf = findChannelWithSameBot(channel.id, fingerprint)
297
+ if (duplicateOf) {
298
+ logger.warn('skipping discord gateway connect — another channel already serves this bot', {
299
+ channelId: channel.id,
300
+ servedBy: duplicateOf,
301
+ })
302
+ return
303
+ }
304
+
305
+ const channelState = discordChannelStateSchema.parse(channel.channelState ?? {})
306
+ const resumeState: GatewayResumeState = {
307
+ sessionId: channelState.sessionId,
308
+ sequence: channelState.sequence ?? null,
309
+ resumeGatewayUrl: channelState.resumeGatewayUrl,
310
+ }
311
+
312
+ // Only reached for a channel with no live session (see `handle`): drop the
313
+ // dead handle, if any, before opening its replacement.
314
+ const stale = activeConnections.get(channel.id)
315
+ if (stale) {
316
+ try {
317
+ stale.handle.close()
318
+ } catch {
319
+ /* best-effort close of an already-dead session */
320
+ }
321
+ activeConnections.delete(channel.id)
322
+ }
323
+
324
+ const stateScope: DiscordChannelScope = {
325
+ tenantId: channel.tenantId,
326
+ organizationId: channel.organizationId ?? null,
327
+ }
328
+
329
+ const inboundQueue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.inbound)
330
+ const reactionsQueue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.reactions)
331
+ let botUserId: string | undefined = channelState.botUserId
332
+
333
+ const handle = getDiscordGatewayClient().connect({
334
+ botToken,
335
+ resumeState,
336
+ onMessage: async (message) => {
337
+ // Log receive / drop / enqueue separately. Without these three lines a lost
338
+ // inbound message is indistinguishable from a message correctly dropped by
339
+ // the bot-self guard, and both are indistinguishable from a dead socket —
340
+ // diagnosing it required an external WebSocket probe during QA.
341
+ logger.debug('discord gateway received message', { channelId: channel.id, messageId: message.id })
342
+ const jobPayload = buildInboundMessageJob({ message, channel: scope, botUserId })
343
+ if (!jobPayload) {
344
+ logger.debug('discord inbound message dropped — authored by this bot', {
345
+ channelId: channel.id,
346
+ messageId: message.id,
347
+ })
348
+ return
349
+ }
350
+ await inboundQueue.enqueue(jobPayload as unknown as Record<string, unknown>)
351
+ logger.debug('discord inbound message enqueued', {
352
+ channelId: channel.id,
353
+ messageId: message.id,
354
+ queue: COMMUNICATION_CHANNELS_QUEUES.inbound,
355
+ })
356
+ },
357
+ onReaction: async (reaction, action) => {
358
+ const reactionJob = await buildReactionJob({ reaction, action, channel: scope, botUserId })
359
+ if (!reactionJob) return
360
+ await reactionsQueue.enqueue(reactionJob as unknown as Record<string, unknown>)
361
+ },
362
+ onReady: async ({ botUserId: readyBotUserId, resumeState: freshResumeState }) => {
363
+ botUserId = readyBotUserId || botUserId
364
+ await persistChannelState(em, channel.id, stateScope, { ...freshResumeState, botUserId })
365
+ logger.info('discord gateway ready', { channelId: channel.id })
366
+ },
367
+ onRequiresReauth: async ({ code }) => {
368
+ logger.warn('discord gateway fatal close — quarantining channel', { channelId: channel.id, code })
369
+ // Persist the status BEFORE dropping the handle. Emitting the event alone
370
+ // is not enough: nothing subscribes to it to change channel state, so the
371
+ // next reconciliation tick would see "no live session" and IDENTIFY again,
372
+ // forever, against a token or intent configuration that cannot self-heal.
373
+ await quarantineChannel(em, channel.id, stateScope, `gateway_close_${code}`)
374
+ await emitCommunicationChannelsEvent(
375
+ 'communication_channels.channel.requires_reauth',
376
+ {
377
+ channelId: channel.id,
378
+ providerKey: 'discord',
379
+ channelType: channel.channelType,
380
+ reason: `gateway_close_${code}`,
381
+ tenantId: channel.tenantId,
382
+ organizationId: channel.organizationId ?? null,
383
+ },
384
+ { persistent: true },
385
+ )
386
+ activeConnections.delete(channel.id)
387
+ },
388
+ })
389
+ activeConnections.set(channel.id, {
390
+ handle,
391
+ tenantId: channel.tenantId,
392
+ organizationId: channel.organizationId ?? null,
393
+ botTokenFingerprint: fingerprint,
394
+ })
395
+ }
396
+
397
+ /**
398
+ * Persist gateway resume state for one channel, tenant-scoped and serialized.
399
+ *
400
+ * The write is queued behind any write already in flight for the same channel so
401
+ * two socket callbacks cannot interleave their read-modify-write, and it runs
402
+ * through `persistDiscordChannelState`, which filters by `tenant_id` /
403
+ * `organization_id` and only merges the gateway-owned keys.
404
+ */
405
+ async function persistChannelState(
406
+ em: EntityManager,
407
+ channelId: string,
408
+ scope: DiscordChannelScope,
409
+ patch: DiscordChannelStatePatch,
410
+ ): Promise<void> {
411
+ const previous = pendingStateWrites.get(channelId) ?? Promise.resolve()
412
+ const next = previous
413
+ .catch(() => undefined)
414
+ .then(async () => {
415
+ try {
416
+ const result = await persistDiscordChannelState({ em, channelId, scope, patch })
417
+ if (result === 'not_found') {
418
+ logger.warn('discord channel not found in scope while persisting resume state', { channelId })
419
+ }
420
+ } catch (err) {
421
+ // Resume-state persistence is best-effort — a failure just means the next
422
+ // connect re-identifies fresh instead of resuming.
423
+ logger.warn('failed to persist discord gateway resume state', { channelId, err })
424
+ }
425
+ })
426
+ pendingStateWrites.set(channelId, next)
427
+ await next
428
+ if (pendingStateWrites.get(channelId) === next) pendingStateWrites.delete(channelId)
429
+ }
430
+
431
+ /**
432
+ * Park a fatally-rejected channel, serialized behind the same per-channel write
433
+ * chain as the resume-state writes so a quarantine and a late `READY` callback
434
+ * cannot interleave their read-modify-write on the same row.
435
+ */
436
+ async function quarantineChannel(
437
+ em: EntityManager,
438
+ channelId: string,
439
+ scope: DiscordChannelScope,
440
+ reason: string,
441
+ ): Promise<void> {
442
+ const previous = pendingStateWrites.get(channelId) ?? Promise.resolve()
443
+ const next = previous
444
+ .catch(() => undefined)
445
+ .then(async () => {
446
+ try {
447
+ const result = await quarantineDiscordChannel({ em, channelId, scope, reason })
448
+ if (result === 'not_found') {
449
+ logger.warn('discord channel not found in scope while quarantining', { channelId })
450
+ }
451
+ } catch (err) {
452
+ logger.warn('failed to quarantine discord channel after fatal close', { channelId, reason, err })
453
+ }
454
+ })
455
+ pendingStateWrites.set(channelId, next)
456
+ await next
457
+ if (pendingStateWrites.get(channelId) === next) pendingStateWrites.delete(channelId)
458
+ }