@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,83 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+ import {
5
+ deleteChannelIfExists,
6
+ isChannelSeedingAvailable,
7
+ seedConnectedChannel,
8
+ seedInboundMessage,
9
+ } from '@open-mercato/core/helpers/integration/communicationChannelsFixtures'
10
+
11
+ /**
12
+ * TC-CHANNEL-DISCORD-004 — reactions on a Discord-provider message.
13
+ * Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
14
+ *
15
+ * `MESSAGE_REACTION_ADD` / `MESSAGE_REACTION_REMOVE` are bridged into the hub's
16
+ * existing reaction routes, which resolve the owning channel through the
17
+ * `ChannelThreadMapping` an inbound message leaves behind. This spec proves that
18
+ * resolution works when the inbound traffic carries `providerKey: 'discord'` —
19
+ * i.e. the provider needs no reaction storage of its own — by adding and then
20
+ * removing a reaction on a seeded Discord message.
21
+ *
22
+ * Driven via the env-gated test-seed fixture (`OM_ENABLE_TEST_CHANNEL_SEEDING`);
23
+ * skips when the gate is off.
24
+ */
25
+ test.describe('TC-CHANNEL-DISCORD-004: reactions round-trip on a discord message', () => {
26
+ test('a reaction can be added to and removed from a discord-provider message', async ({
27
+ request,
28
+ }) => {
29
+ test.slow()
30
+ let token: string | null = null
31
+ let channelId: string | null = null
32
+ try {
33
+ token = await getAuthToken(request, 'admin')
34
+ const seedingAvailable = await isChannelSeedingAvailable(request, token)
35
+ test.skip(
36
+ !seedingAvailable,
37
+ 'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot emit inbound messages.',
38
+ )
39
+
40
+ const stamp = Date.now()
41
+ channelId = await seedConnectedChannel(request, token, {
42
+ displayName: `TC-CHANNEL-DISCORD-004 ${stamp}`,
43
+ externalIdentifier: `discord-004-${stamp}@test-seed.local`,
44
+ })
45
+
46
+ const seeded = await seedInboundMessage(request, token, {
47
+ channelId,
48
+ providerKey: 'discord',
49
+ from: `discord-user-${stamp}`,
50
+ subject: `Discord reaction target ${stamp}`,
51
+ bodyText: 'react to me',
52
+ messageId: `discord-message-${stamp}`,
53
+ // The reaction route resolves the owning channel through this mapping.
54
+ createThreadMapping: true,
55
+ })
56
+
57
+ const added = await apiRequest(
58
+ request,
59
+ 'POST',
60
+ `/api/communication_channels/messages/${seeded.messageId}/reactions`,
61
+ { token, data: { emoji: '👍' } },
62
+ )
63
+ expect(added.status(), 'adding a reaction to a discord message should return 201').toBe(201)
64
+ const addedBody = await readJsonSafe<{ id?: string; emoji?: string; messageId?: string }>(added)
65
+ expect(addedBody?.emoji, 'the reaction echoes the emoji').toBe('👍')
66
+ expect(addedBody?.messageId, 'the reaction is bound to the seeded discord message').toBe(
67
+ seeded.messageId,
68
+ )
69
+
70
+ const reactionId = addedBody?.id
71
+ expect(reactionId, 'the reaction response must carry an id to remove it by').toBeTruthy()
72
+ const removed = await apiRequest(
73
+ request,
74
+ 'DELETE',
75
+ `/api/communication_channels/messages/${seeded.messageId}/reactions/${reactionId}`,
76
+ { token },
77
+ )
78
+ expect(removed.status(), 'removing the reaction should return 204').toBe(204)
79
+ } finally {
80
+ await deleteChannelIfExists(request, token, channelId)
81
+ }
82
+ })
83
+ })
@@ -0,0 +1,156 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import {
3
+ createDiscordSigner,
4
+ freshTimestamp,
5
+ interactionsUrl,
6
+ pingInteractionBody,
7
+ slashCommandInteractionBody,
8
+ staleTimestamp,
9
+ } from './helpers/discordSignature'
10
+
11
+ /**
12
+ * TC-CHANNEL-DISCORD-005 — Interactions endpoint security (fail-closed).
13
+ * Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
14
+ *
15
+ * `POST /api/channel_discord/interactions` is deliberately unauthenticated at
16
+ * the platform layer — the Ed25519 signature IS the auth. This spec drives the
17
+ * REAL route (DB fan-out over candidate channels included) and asserts it is
18
+ * fail-closed on every rejection path:
19
+ *
20
+ * - no signature headers at all → 401
21
+ * - a syntactically valid but wrong signature → 401
22
+ * - a body altered after signing → 401
23
+ * - a signed timestamp outside the replay window → 401 `stale_timestamp`,
24
+ * which also proves the freshness guard runs BEFORE the per-candidate
25
+ * verify fan-out (constant cost for a replayed capture)
26
+ * - a fully-formed slash command signed by an unknown key → 401, never a
27
+ * deferred ack: since #4663 a deferred ack promises a hub write and a
28
+ * follow-up, so it must stay strictly behind the signature gate
29
+ *
30
+ * A rejection must never leak which tenants have Discord channels, so the
31
+ * response body is asserted to carry nothing but the error code.
32
+ *
33
+ * The success paths — PONG (`{ type: 1 }`) and the dispatched slash command /
34
+ * component (`{ type: 5 }` plus the hub job and the Discord follow-up) — need a
35
+ * channel whose stored public key matches the signer; connecting one requires a
36
+ * live bot token. That half stays unit-tested, in
37
+ * `lib/__tests__/interactions-handler.test.ts`,
38
+ * `lib/__tests__/interactions-dispatch.test.ts`,
39
+ * `lib/__tests__/capabilities.test.ts` (the capability parity check) and
40
+ * `workers/__tests__/discord-interactions.test.ts`.
41
+ */
42
+ test.describe('TC-CHANNEL-DISCORD-005: interactions endpoint is fail-closed', () => {
43
+ test('an unsigned interaction is rejected', async ({ request }) => {
44
+ const bare = await request.post(interactionsUrl(), {
45
+ headers: { 'content-type': 'application/json' },
46
+ data: pingInteractionBody(),
47
+ })
48
+ expect(bare.status(), 'a request with no signature headers must be rejected').toBe(401)
49
+ expect(
50
+ await bare.json(),
51
+ 'with no signed timestamp the freshness guard answers first — it runs before the signature fan-out',
52
+ ).toEqual({ error: 'stale_timestamp' })
53
+
54
+ const timestamped = await request.post(interactionsUrl(), {
55
+ headers: { 'content-type': 'application/json', 'x-signature-timestamp': freshTimestamp() },
56
+ data: pingInteractionBody(),
57
+ })
58
+ expect(timestamped.status(), 'a fresh timestamp with no signature must still be rejected').toBe(401)
59
+ expect(await timestamped.json(), 'the rejection must not leak candidate channels').toEqual({
60
+ error: 'invalid_signature',
61
+ })
62
+ })
63
+
64
+ test('a signature from an unknown key is rejected', async ({ request }) => {
65
+ const signer = createDiscordSigner()
66
+ const timestamp = freshTimestamp()
67
+ const body = pingInteractionBody()
68
+
69
+ const response = await request.post(interactionsUrl(), {
70
+ headers: {
71
+ 'content-type': 'application/json',
72
+ 'x-signature-ed25519': signer.sign(timestamp, body),
73
+ 'x-signature-timestamp': timestamp,
74
+ },
75
+ data: body,
76
+ })
77
+ expect(
78
+ response.status(),
79
+ 'a cryptographically valid signature from a key no channel stores must still be rejected',
80
+ ).toBe(401)
81
+ expect(await response.json()).toEqual({ error: 'invalid_signature' })
82
+ })
83
+
84
+ test('a body altered after signing is rejected', async ({ request }) => {
85
+ const signer = createDiscordSigner()
86
+ const timestamp = freshTimestamp()
87
+ const signed = pingInteractionBody()
88
+ const tampered = JSON.stringify({ ...JSON.parse(signed), type: 2 })
89
+
90
+ const response = await request.post(interactionsUrl(), {
91
+ headers: {
92
+ 'content-type': 'application/json',
93
+ 'x-signature-ed25519': signer.sign(timestamp, signed),
94
+ 'x-signature-timestamp': timestamp,
95
+ },
96
+ data: tampered,
97
+ })
98
+ expect(response.status(), 'a tampered body must be rejected').toBe(401)
99
+ expect(await response.json()).toEqual({ error: 'invalid_signature' })
100
+ })
101
+
102
+ test('a replayed (stale) timestamp is rejected before the candidate fan-out', async ({
103
+ request,
104
+ }) => {
105
+ const signer = createDiscordSigner()
106
+ const timestamp = staleTimestamp()
107
+ const body = pingInteractionBody()
108
+
109
+ const response = await request.post(interactionsUrl(), {
110
+ headers: {
111
+ 'content-type': 'application/json',
112
+ 'x-signature-ed25519': signer.sign(timestamp, body),
113
+ 'x-signature-timestamp': timestamp,
114
+ },
115
+ data: body,
116
+ })
117
+ expect(response.status(), 'a stale signed timestamp must be rejected').toBe(401)
118
+ expect(
119
+ await response.json(),
120
+ 'the replay guard must answer before any per-candidate verification',
121
+ ).toEqual({ error: 'stale_timestamp' })
122
+ })
123
+
124
+ test('an unverified slash command gets no deferred ack and no dispatch', async ({ request }) => {
125
+ // The endpoint now DISPATCHES verified commands (issue #4663), which means a
126
+ // deferred ack is a promise to write into a tenant's hub and post back to
127
+ // Discord. That promise must stay behind the signature gate: a command
128
+ // signed by a key no channel stores is rejected exactly like a PING, never
129
+ // acknowledged with `{ type: 5 }`.
130
+ const signer = createDiscordSigner()
131
+ const timestamp = freshTimestamp()
132
+ const body = slashCommandInteractionBody()
133
+
134
+ const response = await request.post(interactionsUrl(), {
135
+ headers: {
136
+ 'content-type': 'application/json',
137
+ 'x-signature-ed25519': signer.sign(timestamp, body),
138
+ 'x-signature-timestamp': timestamp,
139
+ },
140
+ data: body,
141
+ })
142
+ expect(response.status(), 'an unverified slash command must be rejected, not deferred').toBe(401)
143
+ expect(await response.json()).toEqual({ error: 'invalid_signature' })
144
+ })
145
+
146
+ test('a non-JSON body cannot crash the endpoint', async ({ request }) => {
147
+ const response = await request.post(interactionsUrl(), {
148
+ headers: { 'content-type': 'application/json' },
149
+ data: 'not json at all',
150
+ })
151
+ expect(
152
+ response.status(),
153
+ 'an unsigned garbage body must be refused by the signature gate, never 5xx',
154
+ ).toBe(401)
155
+ })
156
+ })
@@ -0,0 +1,144 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+ import { deleteEntityIfExists } from '@open-mercato/core/helpers/integration/crmFixtures'
5
+ import {
6
+ deleteChannelIfExists,
7
+ isChannelSeedingAvailable,
8
+ seedConnectedChannel,
9
+ seedInboundMessage,
10
+ } from '@open-mercato/core/helpers/integration/communicationChannelsFixtures'
11
+ import { drainEventsQueue } from './helpers/eventsQueue'
12
+
13
+ /**
14
+ * TC-CHANNEL-DISCORD-006 — contact resolution for an inbound Discord message.
15
+ * Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
16
+ *
17
+ * The provider deliberately ships NO contact-resolution logic: an inbound
18
+ * Discord message reaches the CRM through the same hub event and the same
19
+ * persistent `customers:link-channel-message-received` subscriber as e-mail.
20
+ * This spec proves that claim end to end — a Discord-provider inbound message
21
+ * whose sender matches a Person's stored address lands exactly one interaction
22
+ * on that Person's timeline, attributed to the `discord` provider.
23
+ *
24
+ * That "exactly one" matters: a provider that duplicated the hub's linking would
25
+ * show up here as two interactions.
26
+ *
27
+ * Driven via the env-gated test-seed fixture (`OM_ENABLE_TEST_CHANNEL_SEEDING`);
28
+ * skips when the gate is off.
29
+ */
30
+ type Interaction = {
31
+ id: string
32
+ interactionType: string
33
+ title: string | null
34
+ body: string | null
35
+ visibility: string | null
36
+ authorUserId: string | null
37
+ }
38
+
39
+ async function drainAndListInteractions(
40
+ request: Parameters<typeof apiRequest>[0],
41
+ token: string,
42
+ personId: string,
43
+ timeoutMs = 20_000,
44
+ ): Promise<Interaction[]> {
45
+ const deadline = Date.now() + timeoutMs
46
+ let last: Interaction[] = []
47
+ while (Date.now() < deadline) {
48
+ await drainEventsQueue()
49
+ const response = await apiRequest(
50
+ request,
51
+ 'GET',
52
+ `/api/customers/interactions?entityId=${encodeURIComponent(personId)}&interactionType=email`,
53
+ { token },
54
+ )
55
+ if (response.ok()) {
56
+ const body = await readJsonSafe<{ items?: Interaction[] }>(response)
57
+ last = Array.isArray(body?.items) ? body!.items : []
58
+ if (last.length > 0) return last
59
+ }
60
+ await new Promise((resolve) => setTimeout(resolve, 250))
61
+ }
62
+ return last
63
+ }
64
+
65
+ test.describe('TC-CHANNEL-DISCORD-006: inbound discord message resolves to a contact', () => {
66
+ test('an inbound discord message from a known contact creates exactly one interaction', async ({
67
+ request,
68
+ }) => {
69
+ test.slow()
70
+ let token: string | null = null
71
+ let personId: string | null = null
72
+ let channelId: string | null = null
73
+ let interactionId: string | null = null
74
+ try {
75
+ token = await getAuthToken(request, 'admin')
76
+ const seedingAvailable = await isChannelSeedingAvailable(request, token)
77
+ test.skip(
78
+ !seedingAvailable,
79
+ 'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot emit inbound messages.',
80
+ )
81
+
82
+ const stamp = Date.now()
83
+ const senderAddress = `discord-006-sender-${stamp}@example.com`
84
+
85
+ const person = await apiRequest(request, 'POST', '/api/customers/people', {
86
+ token,
87
+ data: {
88
+ firstName: 'Discord006',
89
+ lastName: `Contact${stamp}`,
90
+ displayName: `Discord006 Contact ${stamp}`,
91
+ primaryEmail: senderAddress,
92
+ },
93
+ })
94
+ expect(person.ok(), 'creating the contact fixture should succeed').toBeTruthy()
95
+ const personBody = await readJsonSafe<{ id?: string }>(person)
96
+ personId = personBody?.id ?? null
97
+ expect(personId, 'the person fixture must return an id').toBeTruthy()
98
+
99
+ channelId = await seedConnectedChannel(request, token, {
100
+ displayName: `TC-CHANNEL-DISCORD-006 ${stamp}`,
101
+ externalIdentifier: `discord-006-${stamp}@test-seed.local`,
102
+ })
103
+
104
+ const subject = `Discord inbound from a known contact ${stamp}`
105
+ const bodyText = 'this should land on the CRM timeline'
106
+ await seedInboundMessage(request, token, {
107
+ channelId,
108
+ providerKey: 'discord',
109
+ from: senderAddress,
110
+ subject,
111
+ bodyText,
112
+ messageId: `discord-message-006-${stamp}`,
113
+ })
114
+
115
+ const items = await drainAndListInteractions(request, token, personId as string)
116
+ expect(items.length, 'exactly one interaction must be linked to the contact').toBe(1)
117
+ const interaction = items[0]
118
+ interactionId = interaction.id
119
+ expect(interaction.interactionType, 'channel messages land as email interactions').toBe('email')
120
+ // Title + body identify THIS Discord message. The interactions list
121
+ // projection does not expose `externalMessageId` / `channelProviderKey`,
122
+ // so link identity and provider attribution are asserted where they are
123
+ // written, in the hub handler's own unit tests.
124
+ expect(interaction.title, 'the interaction must carry the discord message subject').toBe(subject)
125
+ expect(interaction.body, 'the interaction must carry the discord message body').toBe(bodyText)
126
+ expect(
127
+ interaction.visibility,
128
+ 'a user-owned channel produces a private inbound interaction',
129
+ ).toBe('private')
130
+ expect(
131
+ interaction.authorUserId,
132
+ 'the interaction must be authored by the channel owner, not left unattributed',
133
+ ).toBeTruthy()
134
+ } finally {
135
+ if (token) {
136
+ await deleteEntityIfExists(request, token, '/api/customers/interactions', interactionId)
137
+ }
138
+ await deleteChannelIfExists(request, token, channelId)
139
+ if (token) {
140
+ await deleteEntityIfExists(request, token, '/api/customers/people', personId)
141
+ }
142
+ }
143
+ })
144
+ })
@@ -0,0 +1,119 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+ import {
5
+ deleteChannelIfExists,
6
+ isChannelSeedingAvailable,
7
+ seedConnectedChannel,
8
+ seedInboundMessage,
9
+ } from '@open-mercato/core/helpers/integration/communicationChannelsFixtures'
10
+
11
+ /**
12
+ * TC-CHANNEL-DISCORD-007 — health surface for a channel carrying Discord traffic.
13
+ * Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
14
+ *
15
+ * Operators diagnose a Discord channel through the hub's health route, so this
16
+ * spec pins that surface: it is tenant-scoped (401 unauthenticated, 400 on a
17
+ * malformed id, 404 for a channel the caller does not own) and it reports a
18
+ * fixed numeric shape that includes Discord-provider links.
19
+ *
20
+ * The provider's own probe (`channelDiscordHealthCheck`: healthy on a valid bot
21
+ * token, unhealthy on Discord's 401) calls the Discord API and is unit-tested
22
+ * against a stubbed REST client in `lib/__tests__/adapter.test.ts`.
23
+ */
24
+ const UNKNOWN_CHANNEL_ID = '00000000-0000-4000-8000-0000000d1c07'
25
+
26
+ test.describe('TC-CHANNEL-DISCORD-007: channel health surface', () => {
27
+ test('requires authentication', async ({ request }) => {
28
+ const response = await apiRequest(
29
+ request,
30
+ 'GET',
31
+ `/api/communication_channels/channels/${UNKNOWN_CHANNEL_ID}/health`,
32
+ // Intentionally empty token — this assertion is the 401 unauth path.
33
+ { token: '' },
34
+ )
35
+ expect(response.status(), 'health must reject an unauthenticated caller').toBe(401)
36
+ })
37
+
38
+ test('rejects a malformed channel id', async ({ request }) => {
39
+ const token = await getAuthToken(request, 'admin')
40
+ const response = await apiRequest(
41
+ request,
42
+ 'GET',
43
+ '/api/communication_channels/channels/not-a-uuid/health',
44
+ { token },
45
+ )
46
+ expect(response.status(), 'a non-uuid channel id must be a 400, not a 500').toBe(400)
47
+ })
48
+
49
+ test('rejects a channel the caller does not own', async ({ request }) => {
50
+ const token = await getAuthToken(request, 'admin')
51
+ const response = await apiRequest(
52
+ request,
53
+ 'GET',
54
+ `/api/communication_channels/channels/${UNKNOWN_CHANNEL_ID}/health`,
55
+ { token },
56
+ )
57
+ expect(response.status(), 'an unknown/foreign channel must be 404').toBe(404)
58
+ })
59
+
60
+ test('reports a numeric delivery snapshot that includes discord traffic', async ({ request }) => {
61
+ test.slow()
62
+ let token: string | null = null
63
+ let channelId: string | null = null
64
+ try {
65
+ token = await getAuthToken(request, 'admin')
66
+ const seedingAvailable = await isChannelSeedingAvailable(request, token)
67
+ test.skip(
68
+ !seedingAvailable,
69
+ 'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot seed a channel.',
70
+ )
71
+
72
+ const stamp = Date.now()
73
+ channelId = await seedConnectedChannel(request, token, {
74
+ displayName: `TC-CHANNEL-DISCORD-007 ${stamp}`,
75
+ externalIdentifier: `discord-007-${stamp}@test-seed.local`,
76
+ })
77
+ await seedInboundMessage(request, token, {
78
+ channelId,
79
+ providerKey: 'discord',
80
+ from: `discord-user-${stamp}`,
81
+ subject: `Discord health seed ${stamp}`,
82
+ bodyText: 'seed message for the health window',
83
+ messageId: `discord-message-007-${stamp}`,
84
+ })
85
+
86
+ const response = await apiRequest(
87
+ request,
88
+ 'GET',
89
+ `/api/communication_channels/channels/${channelId}/health`,
90
+ { token },
91
+ )
92
+ expect(response.status(), 'GET /channels/[id]/health should return 200').toBe(200)
93
+ const body = await readJsonSafe<{
94
+ channelId?: string
95
+ windowHours?: number
96
+ counts?: Record<string, number>
97
+ totalsLast24h?: number
98
+ recentFailures?: unknown[]
99
+ }>(response)
100
+
101
+ expect(body?.channelId, 'health must be scoped to the requested channel').toBe(channelId)
102
+ expect(typeof body?.windowHours, 'the health window must be reported').toBe('number')
103
+ expect(
104
+ Object.keys(body?.counts ?? {}),
105
+ 'the delivery-status breakdown must keep its fixed shape',
106
+ ).toEqual(expect.arrayContaining(['sent', 'delivered', 'failed', 'pending', 'queued']))
107
+ for (const [status, value] of Object.entries(body?.counts ?? {})) {
108
+ expect(typeof value, `counts.${status} must be numeric`).toBe('number')
109
+ }
110
+ expect(
111
+ body?.totalsLast24h ?? 0,
112
+ 'the seeded discord message must be inside the 24h window',
113
+ ).toBeGreaterThanOrEqual(1)
114
+ expect(Array.isArray(body?.recentFailures), 'recentFailures must always be an array').toBe(true)
115
+ } finally {
116
+ await deleteChannelIfExists(request, token, channelId)
117
+ }
118
+ })
119
+ })
@@ -0,0 +1,75 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { createDiscordSigner, freshTimestamp, interactionsUrl, pingInteractionBody } from './helpers/discordSignature'
3
+
4
+ /**
5
+ * TC-CHANNEL-DISCORD-008 — tenant isolation on the shared interactions endpoint.
6
+ * Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
7
+ *
8
+ * One unauthenticated URL serves every tenant's Discord application, so the
9
+ * candidate fan-out is the isolation boundary: a request is pinned to the single
10
+ * channel whose stored public key verifies it, and a request that verifies
11
+ * against none must be indistinguishable from any other rejection.
12
+ *
13
+ * This spec drives the real route with two independent applications' keys and
14
+ * asserts the endpoint is a black box: identical status, identical body, no
15
+ * channel id, tenant id, organization id or count anywhere in the response, and
16
+ * no timing-visible difference in shape between them. That is the observable
17
+ * half of the isolation contract on an instance with no connected Discord
18
+ * channel — the positive half (a signature pinning exactly its own tenant, and
19
+ * never another's) requires two live bot tokens and is unit-tested as
20
+ * "per-tenant public-key pinning" in `lib/__tests__/interactions-handler.test.ts`.
21
+ */
22
+ const LEAKY_KEYS = ['channelId', 'tenantId', 'organizationId', 'candidates', 'channels']
23
+
24
+ test.describe('TC-CHANNEL-DISCORD-008: interactions endpoint leaks no tenant state', () => {
25
+ test('two different applications get byte-identical rejections', async ({ request }) => {
26
+ const bodies: unknown[] = []
27
+ const statuses: number[] = []
28
+
29
+ for (const label of ['tenant-a', 'tenant-b']) {
30
+ const signer = createDiscordSigner()
31
+ const timestamp = freshTimestamp()
32
+ const body = JSON.stringify({ ...JSON.parse(pingInteractionBody()), application_id: label })
33
+ const response = await request.post(interactionsUrl(), {
34
+ headers: {
35
+ 'content-type': 'application/json',
36
+ 'x-signature-ed25519': signer.sign(timestamp, body),
37
+ 'x-signature-timestamp': timestamp,
38
+ },
39
+ data: body,
40
+ })
41
+ statuses.push(response.status())
42
+ bodies.push(await response.json())
43
+ }
44
+
45
+ expect(statuses, 'both applications must be rejected the same way').toEqual([401, 401])
46
+ expect(
47
+ bodies[0],
48
+ 'the rejection body must not vary with the caller — that variance would be a tenant oracle',
49
+ ).toEqual(bodies[1])
50
+ })
51
+
52
+ test('a rejection body carries no tenant-scoped identifiers', async ({ request }) => {
53
+ const signer = createDiscordSigner()
54
+ const timestamp = freshTimestamp()
55
+ const body = pingInteractionBody()
56
+ const response = await request.post(interactionsUrl(), {
57
+ headers: {
58
+ 'content-type': 'application/json',
59
+ 'x-signature-ed25519': signer.sign(timestamp, body),
60
+ 'x-signature-timestamp': timestamp,
61
+ },
62
+ data: body,
63
+ })
64
+
65
+ expect(response.status()).toBe(401)
66
+ const raw = await response.text()
67
+ for (const key of LEAKY_KEYS) {
68
+ expect(raw, `an unverified caller must not learn "${key}"`).not.toContain(key)
69
+ }
70
+ expect(
71
+ Object.keys((await response.json()) as Record<string, unknown>),
72
+ 'the rejection body is exactly one error code',
73
+ ).toEqual(['error'])
74
+ })
75
+ })
@@ -0,0 +1,77 @@
1
+ import { createPrivateKey, generateKeyPairSync, sign as signEd25519 } from 'node:crypto'
2
+
3
+ /**
4
+ * Ed25519 request-signing helpers for the Discord Interactions integration
5
+ * tests. Discord signs `timestamp + rawBody` with the application's Ed25519 key
6
+ * and sends the signature in `X-Signature-Ed25519`; the provider route verifies
7
+ * it against every candidate channel's stored public key and is fail-closed.
8
+ *
9
+ * The tests generate their own keypair so they never need a real Discord
10
+ * application, and so a signature that is cryptographically perfect but belongs
11
+ * to no connected channel is still rejected — which is the property under test.
12
+ */
13
+
14
+ export type DiscordSigner = {
15
+ /** Raw 32-byte Ed25519 public key, hex encoded — the shape Discord shows in the portal. */
16
+ publicKeyHex: string
17
+ /** Sign `timestamp + body` exactly the way Discord does. */
18
+ sign: (timestamp: string, body: string) => string
19
+ }
20
+
21
+ export function createDiscordSigner(): DiscordSigner {
22
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519')
23
+ const spki = publicKey.export({ format: 'der', type: 'spki' }) as Buffer
24
+ // SPKI for Ed25519 is a fixed 12-byte header followed by the raw 32-byte key.
25
+ const rawPublicKey = spki.subarray(spki.length - 32)
26
+ const key = createPrivateKey(privateKey.export({ format: 'pem', type: 'pkcs8' }) as string)
27
+ return {
28
+ publicKeyHex: rawPublicKey.toString('hex'),
29
+ sign: (timestamp: string, body: string) =>
30
+ signEd25519(null, Buffer.from(`${timestamp}${body}`, 'utf8'), key).toString('hex'),
31
+ }
32
+ }
33
+
34
+ /** A PING interaction body — the handshake Discord sends when an endpoint URL is saved. */
35
+ export function pingInteractionBody(): string {
36
+ return JSON.stringify({ type: 1, id: 'integration-ping', application_id: 'integration' })
37
+ }
38
+
39
+ /**
40
+ * A fully-formed `APPLICATION_COMMAND` body — everything the dispatch path needs
41
+ * (id, follow-up token, application, channel and invoking user), so a rejection
42
+ * can only come from the signature gate and never from an incomplete payload.
43
+ */
44
+ export function slashCommandInteractionBody(): string {
45
+ return JSON.stringify({
46
+ type: 2,
47
+ id: 'integration-command',
48
+ token: 'integration-follow-up-token',
49
+ application_id: 'integration',
50
+ channel_id: 'integration-channel',
51
+ member: { user: { id: 'integration-user', username: 'integration' } },
52
+ data: { name: 'mercato', options: [{ name: 'message', value: 'integration probe' }] },
53
+ })
54
+ }
55
+
56
+ /** Current unix seconds as the signed timestamp header value. */
57
+ export function freshTimestamp(): string {
58
+ return String(Math.floor(Date.now() / 1000))
59
+ }
60
+
61
+ /** A timestamp far outside the provider's replay window (DISCORD_SIGNATURE_MAX_SKEW_SECONDS = 300). */
62
+ export function staleTimestamp(): string {
63
+ return String(Math.floor(Date.now() / 1000) - 3600)
64
+ }
65
+
66
+ export const INTERACTIONS_PATH = '/api/channel_discord/interactions'
67
+
68
+ /**
69
+ * The interactions endpoint is posted with a RAW body (the signature covers the
70
+ * exact bytes), so these specs bypass `apiRequest` and call Playwright's request
71
+ * context directly — which means they must resolve `BASE_URL` the same way the
72
+ * shared helper does.
73
+ */
74
+ export function interactionsUrl(): string {
75
+ const base = process.env.BASE_URL?.trim()
76
+ return base ? `${base}${INTERACTIONS_PATH}` : INTERACTIONS_PATH
77
+ }
@@ -0,0 +1,26 @@
1
+ import path from 'node:path'
2
+ import { config as loadEnv } from 'dotenv'
3
+ import { drainIntegrationQueue } from '@open-mercato/core/helpers/integration/queue'
4
+
5
+ /**
6
+ * The hub emits `communication_channels.message.received` through the `events`
7
+ * queue, where the persistent `customers:link-channel-message-received`
8
+ * subscriber consumes it. Integration specs that assert on the CRM side of an
9
+ * inbound Discord message must drain that queue first.
10
+ *
11
+ * Mirrors the customers module's own inbound-email helper: the Playwright
12
+ * process is the monorepo while the job was created by the app under test, so
13
+ * the drain runs from the app root (`OM_TEST_APP_ROOT` when set).
14
+ */
15
+ export const APP_ROOT = process.env.OM_TEST_APP_ROOT?.trim()
16
+ ? path.resolve(process.env.OM_TEST_APP_ROOT as string)
17
+ : path.resolve(process.cwd(), 'apps/mercato')
18
+
19
+ if (!process.env.OM_TEST_APP_ROOT?.trim()) {
20
+ loadEnv({ path: path.resolve(APP_ROOT, '.env') })
21
+ process.env.QUEUE_BASE_DIR = path.resolve(APP_ROOT, '.mercato/queue')
22
+ }
23
+
24
+ export async function drainEventsQueue(): Promise<void> {
25
+ await drainIntegrationQueue('events', { appRoot: APP_ROOT })
26
+ }