@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,174 @@
1
+ import { createPublicKey, verify as cryptoVerify } from 'node:crypto'
2
+
3
+ /**
4
+ * Discord interaction request signing (Ed25519).
5
+ *
6
+ * Every interaction POST carries `X-Signature-Ed25519` (hex) and
7
+ * `X-Signature-Timestamp`. The signed payload is `timestamp + rawBody`, verified
8
+ * against the application's Ed25519 **public key** (hex, from the General
9
+ * Information tab). We verify with Node's built-in `crypto` (`ed25519`) — no
10
+ * `tweetnacl` dependency.
11
+ *
12
+ * SECURITY CONTRACT: this function is FAIL-CLOSED. It returns `false` on a
13
+ * missing header, a malformed key/signature, or any verification error — never
14
+ * throws, never returns `true` on doubt. Callers MUST reject (`401`) on `false`.
15
+ */
16
+
17
+ // Standard SPKI DER prefix for an Ed25519 public key (RFC 8410). Prepending it
18
+ // to the 32 raw key bytes yields a DER document `createPublicKey` accepts,
19
+ // avoiding a third-party crypto dependency.
20
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
21
+
22
+ function isHex(value: string): boolean {
23
+ return value.length > 0 && value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value)
24
+ }
25
+
26
+ /** An Ed25519 signature is 64 bytes, so 128 hex characters. */
27
+ const ED25519_SIGNATURE_HEX_LENGTH = 128
28
+
29
+ /**
30
+ * Whether the request's signature headers are even capable of verifying —
31
+ * present, hex, and the right length. This depends on the REQUEST ALONE, never
32
+ * on a candidate channel, which is why it is exported separately: the route runs
33
+ * it before it loads any channel, so an unsigned or malformed POST is rejected
34
+ * without a single database round-trip.
35
+ *
36
+ * `verifyDiscordSignature` applies the same guard itself, so this is an
37
+ * optimisation of *when* the answer is known, never a replacement for the
38
+ * fail-closed check.
39
+ */
40
+ export function hasVerifiableSignatureHeaders(
41
+ signatureHex: string | undefined | null,
42
+ timestamp: string | undefined | null,
43
+ ): signatureHex is string {
44
+ if (!signatureHex || !timestamp) return false
45
+ return isHex(signatureHex) && signatureHex.length === ED25519_SIGNATURE_HEX_LENGTH
46
+ }
47
+
48
+ function publicKeyFromHex(publicKeyHex: string) {
49
+ if (!isHex(publicKeyHex) || publicKeyHex.length !== 64) {
50
+ throw new Error('invalid ed25519 public key')
51
+ }
52
+ const raw = Buffer.from(publicKeyHex, 'hex')
53
+ const der = Buffer.concat([ED25519_SPKI_PREFIX, raw])
54
+ return createPublicKey({ key: der, format: 'der', type: 'spki' })
55
+ }
56
+
57
+ export interface VerifyDiscordSignatureInput {
58
+ publicKeyHex: string
59
+ signatureHex: string | undefined | null
60
+ timestamp: string | undefined | null
61
+ rawBody: string
62
+ }
63
+
64
+ /**
65
+ * Verify a Discord interaction signature. Returns `true` only when the signature
66
+ * cryptographically matches `timestamp + rawBody` under `publicKeyHex`.
67
+ */
68
+ export function verifyDiscordSignature(input: VerifyDiscordSignatureInput): boolean {
69
+ const { publicKeyHex, signatureHex, timestamp, rawBody } = input
70
+ if (!hasVerifiableSignatureHeaders(signatureHex, timestamp)) return false
71
+ try {
72
+ const key = publicKeyFromHex(publicKeyHex)
73
+ const message = Buffer.from(String(timestamp) + rawBody, 'utf-8')
74
+ const signature = Buffer.from(signatureHex, 'hex')
75
+ // Ed25519: the algorithm is implied by the key, so the first arg is null.
76
+ return cryptoVerify(null, message, key, signature)
77
+ } catch {
78
+ return false
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Replay guard. Discord signs `timestamp + body`, but a captured request stays
84
+ * cryptographically valid forever — freshness has to be enforced separately.
85
+ * A timestamp outside the ± skew window (or missing / non-numeric) is rejected,
86
+ * fail-closed, so a recorded interaction cannot be replayed later.
87
+ */
88
+ export const DISCORD_SIGNATURE_MAX_SKEW_SECONDS = 300
89
+
90
+ export interface TimestampFreshnessOptions {
91
+ maxSkewSeconds?: number
92
+ /** Injectable clock (epoch seconds) for tests; defaults to the real clock. */
93
+ nowEpochSeconds?: number
94
+ }
95
+
96
+ export function isSignatureTimestampFresh(
97
+ timestamp: string | undefined | null,
98
+ options?: TimestampFreshnessOptions,
99
+ ): boolean {
100
+ if (!timestamp) return false
101
+ const value = String(timestamp).trim()
102
+ if (!/^\d{1,12}$/.test(value)) return false
103
+ const parsedSeconds = Number(value)
104
+ if (!Number.isSafeInteger(parsedSeconds)) return false
105
+ const maxSkewSeconds = options?.maxSkewSeconds ?? DISCORD_SIGNATURE_MAX_SKEW_SECONDS
106
+ const nowSeconds = options?.nowEpochSeconds ?? Math.floor(Date.now() / 1000)
107
+ return Math.abs(nowSeconds - parsedSeconds) <= maxSkewSeconds
108
+ }
109
+
110
+ /** Discord interaction types. */
111
+ export const DiscordInteractionType = {
112
+ PING: 1,
113
+ APPLICATION_COMMAND: 2,
114
+ MESSAGE_COMPONENT: 3,
115
+ APPLICATION_COMMAND_AUTOCOMPLETE: 4,
116
+ MODAL_SUBMIT: 5,
117
+ } as const
118
+
119
+ /** Discord interaction response types. */
120
+ export const DiscordInteractionResponseType = {
121
+ PONG: 1,
122
+ CHANNEL_MESSAGE_WITH_SOURCE: 4,
123
+ DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE: 5,
124
+ DEFERRED_UPDATE_MESSAGE: 6,
125
+ // Autocomplete is the one interaction type Discord will not accept a deferred
126
+ // ack for — it must be answered synchronously with this type and a choice list.
127
+ APPLICATION_COMMAND_AUTOCOMPLETE_RESULT: 8,
128
+ } as const
129
+
130
+ export interface ParsedInteraction {
131
+ type: number
132
+ data?: Record<string, unknown>
133
+ member?: { user?: { id?: string; username?: string; global_name?: string | null } }
134
+ user?: { id?: string; username?: string; global_name?: string | null }
135
+ channel_id?: string
136
+ guild_id?: string
137
+ application_id?: string
138
+ id?: string
139
+ token?: string
140
+ [key: string]: unknown
141
+ }
142
+
143
+ /**
144
+ * Parse the interaction body. Returns `null` when the body is not a JSON object
145
+ * with a numeric `type` — the caller treats that as a non-interaction payload.
146
+ */
147
+ export function parseInteractionBody(rawBody: string): ParsedInteraction | null {
148
+ try {
149
+ const parsed = JSON.parse(rawBody) as unknown
150
+ if (!parsed || typeof parsed !== 'object') return null
151
+ const candidate = parsed as { type?: unknown }
152
+ if (typeof candidate.type !== 'number') return null
153
+ return parsed as ParsedInteraction
154
+ } catch {
155
+ return null
156
+ }
157
+ }
158
+
159
+ /**
160
+ * The `application_id` an interaction body claims, or `null` when the body does
161
+ * not carry a usable one.
162
+ *
163
+ * UNTRUSTED — this value is read before any signature has been verified, so it
164
+ * may only ever be used to NARROW the set of candidate channels the signature is
165
+ * then checked against. It is never an authorization decision: a body claiming
166
+ * another tenant's application still has to carry that tenant's Ed25519
167
+ * signature to be accepted, and a body claiming nothing falls back to the full
168
+ * candidate set rather than being let through.
169
+ */
170
+ export function readInteractionApplicationId(rawBody: string): string | null {
171
+ const interaction = parseInteractionBody(rawBody)
172
+ const applicationId = interaction?.application_id
173
+ return typeof applicationId === 'string' && applicationId.length > 0 ? applicationId : null
174
+ }
@@ -0,0 +1,89 @@
1
+ import type {
2
+ NormalizedInboundMessage,
3
+ NormalizedAttachment,
4
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter'
5
+ import type { DiscordMessageObject } from './discord-rest'
6
+
7
+ /**
8
+ * Prefix used to build the hub's `externalConversationId` from a Discord channel
9
+ * (or thread) id. A Discord "channel" (text channel / thread) is the unit the
10
+ * hub threads on, mirroring `gmail-thread:` / Slack channel conventions.
11
+ */
12
+ export const DISCORD_CONVERSATION_PREFIX = 'discord-channel:'
13
+
14
+ export const DISCORD_CHANNEL_CONTENT_TYPE = 'discord'
15
+
16
+ function mapAttachments(message: DiscordMessageObject): NormalizedAttachment[] | undefined {
17
+ if (!Array.isArray(message.attachments) || message.attachments.length === 0) return undefined
18
+ return message.attachments.map((attachment) => ({
19
+ url: attachment.url,
20
+ mimeType: attachment.content_type ?? 'application/octet-stream',
21
+ fileName: attachment.filename,
22
+ fileSize: attachment.size,
23
+ inline: false,
24
+ }))
25
+ }
26
+
27
+ /**
28
+ * Map a raw Discord message object (from `MESSAGE_CREATE` or REST history) into
29
+ * the hub's canonical `NormalizedInboundMessage`.
30
+ *
31
+ * Discord senders have no email/phone — the CRM contact is resolved from the
32
+ * user id / handle later (`resolveContact`). The `externalConversationId` is the
33
+ * channel/thread id (a Discord thread reuses this same shape), so all messages
34
+ * in one channel thread into one hub conversation.
35
+ */
36
+ export function normalizeInboundDiscordMessage(message: DiscordMessageObject): NormalizedInboundMessage {
37
+ const author = message.author ?? ({} as DiscordMessageObject['author'])
38
+ const senderDisplayName = author?.global_name || author?.username || undefined
39
+ const timestamp = message.timestamp ? new Date(message.timestamp) : new Date()
40
+
41
+ return {
42
+ externalMessageId: message.id,
43
+ externalConversationId: `${DISCORD_CONVERSATION_PREFIX}${message.channel_id}`,
44
+ senderIdentifier: author?.id ?? 'unknown',
45
+ senderDisplayName,
46
+ body: message.content ?? '',
47
+ bodyFormat: 'markdown',
48
+ attachments: mapAttachments(message),
49
+ timestamp,
50
+ replyToExternalId: message.message_reference?.message_id,
51
+ channelPayload: message as unknown as Record<string, unknown>,
52
+ channelContentType: DISCORD_CHANNEL_CONTENT_TYPE,
53
+ channelMetadata: {
54
+ discordChannelId: message.channel_id,
55
+ discordGuildId: message.guild_id,
56
+ discordAuthorId: author?.id,
57
+ discordAuthorUsername: author?.username,
58
+ discordAuthorGlobalName: author?.global_name ?? undefined,
59
+ discordAuthorIsBot: Boolean(author?.bot),
60
+ // Only present when the record came from a slash command or a component
61
+ // press rather than someone typing. A consumer that treats every inbound
62
+ // message the same can ignore these; one that answers commands
63
+ // specifically needs to tell them apart without re-parsing `channelPayload`.
64
+ ...(message.discord_interaction
65
+ ? {
66
+ discordInteractionId: message.discord_interaction.id,
67
+ discordInteractionType: message.discord_interaction.type,
68
+ discordCommandName: message.discord_interaction.commandName,
69
+ discordComponentCustomId: message.discord_interaction.customId,
70
+ }
71
+ : {}),
72
+ },
73
+ }
74
+ }
75
+
76
+ /**
77
+ * True when a raw message must be dropped as bot-authored. The gateway worker
78
+ * uses this as the feedback-loop guard.
79
+ *
80
+ * We drop ANY message flagged `author.bot` (not just our own bot's user id):
81
+ * other bots/webhooks in the channel would otherwise be ingested and could
82
+ * trigger the AI auto-reply, creating cross-bot loops and noise. The explicit
83
+ * `botUserId` check is retained as a belt-and-suspenders guard for the rare case
84
+ * where our own bot's `author.bot` flag is absent from a payload.
85
+ */
86
+ export function isAuthoredByBot(message: DiscordMessageObject, botUserId: string | undefined): boolean {
87
+ if (message.author?.bot) return true
88
+ return botUserId ? message.author?.id === botUserId : false
89
+ }
@@ -0,0 +1,109 @@
1
+ import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
2
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
3
+ import type { CredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'
4
+ import type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
5
+ import { discordCredentialsSchema, type DiscordCredentials } from './credentials'
6
+
7
+ export const CHANNEL_DISCORD_INTEGRATION_ID = 'channel_discord'
8
+
9
+ export type DiscordEnvPreset = {
10
+ credentials: DiscordCredentials
11
+ force: boolean
12
+ }
13
+
14
+ export type ApplyDiscordPresetResult =
15
+ | { status: 'skipped'; reason: string }
16
+ | { status: 'configured' }
17
+
18
+ function readEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
19
+ const value = env[key]?.trim()
20
+ return value ? value : undefined
21
+ }
22
+
23
+ /**
24
+ * Read the deployment-managed Discord bootstrap credentials from the environment
25
+ * (SPEC 2026-06-19 § Connect the channel in Open Mercato).
26
+ *
27
+ * Returns `null` when no `OM_CHANNEL_DISCORD_*` credential var is set at all —
28
+ * the normal case, where the bot is connected interactively through
29
+ * `/backend/integrations`. When any of them IS set the preset must be complete,
30
+ * so a half-filled deployment fails loudly instead of persisting a bot token
31
+ * that cannot authenticate. The bot token is never logged or echoed.
32
+ */
33
+ export function readDiscordEnvPreset(env: NodeJS.ProcessEnv = process.env): DiscordEnvPreset | null {
34
+ const botToken = readEnvValue(env, 'OM_CHANNEL_DISCORD_BOT_TOKEN')
35
+ const applicationId = readEnvValue(env, 'OM_CHANNEL_DISCORD_APPLICATION_ID')
36
+ const publicKey = readEnvValue(env, 'OM_CHANNEL_DISCORD_PUBLIC_KEY')
37
+ const guildId = readEnvValue(env, 'OM_CHANNEL_DISCORD_GUILD_ID')
38
+ const defaultChannelId = readEnvValue(env, 'OM_CHANNEL_DISCORD_DEFAULT_CHANNEL_ID')
39
+
40
+ if (!botToken && !applicationId && !publicKey) return null
41
+
42
+ const parsed = discordCredentialsSchema.safeParse({
43
+ botToken,
44
+ applicationId,
45
+ publicKey,
46
+ ...(guildId ? { guildId } : {}),
47
+ ...(defaultChannelId ? { defaultChannelId } : {}),
48
+ })
49
+ if (!parsed.success) {
50
+ const first = parsed.error.issues[0]
51
+ throw new Error(
52
+ '[internal] Incomplete Discord env preset. Set OM_CHANNEL_DISCORD_BOT_TOKEN, ' +
53
+ 'OM_CHANNEL_DISCORD_APPLICATION_ID and OM_CHANNEL_DISCORD_PUBLIC_KEY ' +
54
+ `(${first?.path.join('.') ?? 'credentials'}: ${first?.message ?? 'invalid'}).`,
55
+ )
56
+ }
57
+
58
+ return {
59
+ credentials: parsed.data,
60
+ force: parseBooleanToken(env.OM_CHANNEL_DISCORD_FORCE_PRECONFIGURE) ?? false,
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Apply the env preset to one (tenant, organization) scope through the standard
66
+ * integration credential service — never by special-casing the provider in core.
67
+ *
68
+ * Existing credentials win unless `OM_CHANNEL_DISCORD_FORCE_PRECONFIGURE` (or an
69
+ * explicit `force`) says otherwise, so re-running tenant setup or the
70
+ * `configure-from-env` CLI command is safe and idempotent.
71
+ */
72
+ export async function applyDiscordEnvPreset(params: {
73
+ credentialsService: CredentialsService
74
+ integrationLogService?: IntegrationLogService
75
+ scope: IntegrationScope
76
+ force?: boolean
77
+ env?: NodeJS.ProcessEnv
78
+ }): Promise<ApplyDiscordPresetResult> {
79
+ const preset = readDiscordEnvPreset(params.env)
80
+ if (!preset) {
81
+ return { status: 'skipped', reason: 'No Discord preset env variables were provided.' }
82
+ }
83
+
84
+ const force = params.force ?? preset.force
85
+ if (!force) {
86
+ const existing = await params.credentialsService.getRaw(CHANNEL_DISCORD_INTEGRATION_ID, params.scope)
87
+ if (existing) {
88
+ return {
89
+ status: 'skipped',
90
+ reason:
91
+ 'Discord credentials already exist. Use OM_CHANNEL_DISCORD_FORCE_PRECONFIGURE=true to overwrite.',
92
+ }
93
+ }
94
+ }
95
+
96
+ await params.credentialsService.save(CHANNEL_DISCORD_INTEGRATION_ID, preset.credentials, params.scope)
97
+
98
+ if (params.integrationLogService) {
99
+ await params.integrationLogService
100
+ .scoped(CHANNEL_DISCORD_INTEGRATION_ID, params.scope)
101
+ .info('Discord integration was preconfigured from environment variables.', {
102
+ applicationId: preset.credentials.applicationId,
103
+ guildId: preset.credentials.guildId ?? null,
104
+ defaultChannelId: preset.credentials.defaultChannelId ?? null,
105
+ })
106
+ }
107
+
108
+ return { status: 'configured' }
109
+ }
@@ -0,0 +1,29 @@
1
+ import { createLogger } from '@open-mercato/shared/lib/logger'
2
+
3
+ const logger = createLogger('channel_discord').child({ component: 'cli' })
4
+
5
+ /**
6
+ * Refuse to start the gateway when inbound messages cannot possibly be delivered.
7
+ *
8
+ * `start-gateway` is a standalone process that runs a worker for the
9
+ * `channel_discord_gateway` queue only, while the bridge enqueues received
10
+ * messages into `communication-channels-inbound` / `-reactions`, whose workers
11
+ * live in the app server process. Under the DEFAULT `QUEUE_STRATEGY=local` the
12
+ * queue is in-process, so those jobs never cross the process boundary: they are
13
+ * dropped with no error, no log, no retry and no `failed` entry, while the
14
+ * channel keeps reporting `Connected`. Failing loudly at startup is the only
15
+ * honest option — the alternative looks like it works.
16
+ *
17
+ * Lives outside `cli.ts` so it stays unit-testable without pulling the DI
18
+ * container graph into the test.
19
+ */
20
+ export function assertInboundDeliverable(queueStrategy: string | undefined): void {
21
+ if (queueStrategy === 'async') return
22
+ const message =
23
+ 'channel_discord start-gateway requires QUEUE_STRATEGY=async (with Redis configured). ' +
24
+ 'Under the default in-process queue strategy the gateway cannot hand inbound messages to the ' +
25
+ 'communication-channels workers running in the app server process — they would be silently dropped. ' +
26
+ 'Set QUEUE_STRATEGY=async and point the queue at Redis, then start this command again.'
27
+ logger.error(message, { queueStrategy: queueStrategy ?? 'local (default)' })
28
+ throw new Error(`[internal] ${message}`)
29
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Application (slash) commands this provider registers against a guild.
3
+ *
4
+ * NOT LOCALIZED, deliberately: a Discord application registers ONE canonical
5
+ * command name per guild — the localized variants Discord supports live in
6
+ * `name_localizations` / `description_localizations`, which are a per-command
7
+ * map rather than an app locale, and no operator surface exists yet to choose
8
+ * which locales a tenant wants. The registration is also an operator action
9
+ * (`mercato channel_discord register-slash-commands`), not a rendered UI string.
10
+ *
11
+ * The set is deliberately minimal. Every command routes to the same place —
12
+ * the hub inbox — so the provider ships the one command that makes that useful
13
+ * and leaves richer command trees to whoever knows their own workflow. Register
14
+ * a different set by passing `--commands <path-to-json>`.
15
+ */
16
+ export const CHANNEL_DISCORD_SLASH_COMMAND_TYPE_CHAT_INPUT = 1
17
+ export const CHANNEL_DISCORD_OPTION_TYPE_STRING = 3
18
+
19
+ export const DISCORD_DEFAULT_SLASH_COMMANDS: Array<Record<string, unknown>> = [
20
+ {
21
+ name: 'mercato',
22
+ type: CHANNEL_DISCORD_SLASH_COMMAND_TYPE_CHAT_INPUT,
23
+ description: 'Send a message to the team through Open Mercato',
24
+ options: [
25
+ {
26
+ type: CHANNEL_DISCORD_OPTION_TYPE_STRING,
27
+ name: 'message',
28
+ description: 'What do you need?',
29
+ required: true,
30
+ },
31
+ ],
32
+ },
33
+ ]
34
+
35
+ /**
36
+ * Validate an operator-supplied command list read from JSON. Discord rejects a
37
+ * malformed registration with a 400 whose body names an array index, so failing
38
+ * here with the index and the reason is strictly more useful than forwarding it.
39
+ */
40
+ export function parseSlashCommandDefinitions(value: unknown): Array<Record<string, unknown>> {
41
+ if (!Array.isArray(value)) {
42
+ throw new Error('[internal] Discord slash command definitions must be a JSON array.')
43
+ }
44
+ return value.map((entry, index) => {
45
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
46
+ throw new Error(`[internal] Discord slash command at index ${index} is not an object.`)
47
+ }
48
+ const command = entry as Record<string, unknown>
49
+ if (typeof command.name !== 'string' || command.name.length === 0) {
50
+ throw new Error(`[internal] Discord slash command at index ${index} has no name.`)
51
+ }
52
+ if (typeof command.description !== 'string' || command.description.length === 0) {
53
+ throw new Error(`[internal] Discord slash command "${command.name}" has no description.`)
54
+ }
55
+ return command
56
+ })
57
+ }
@@ -0,0 +1,75 @@
1
+ import type { MessageAction, MessageTypeDefinition } from '@open-mercato/shared/modules/messages/types'
2
+ import {
3
+ CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
4
+ CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,
5
+ } from './lib/ai-proposal-contract'
6
+
7
+ /**
8
+ * The message type the `complex`-tier AI proposal is filed under (issue #4778).
9
+ *
10
+ * Declaring it here is what makes the approval surface work at all: the messages
11
+ * module builds its allowlist of dispatchable action commands from the
12
+ * `defaultActions` of every REGISTERED message type
13
+ * (`messages/lib/actions.ts` → `getMessageSafeCommandIds`). An action whose
14
+ * `commandId` is not reachable that way is rejected with "Action command is not
15
+ * allowed" — the confused-deputy guard that stops a composer from making a
16
+ * recipient dispatch an arbitrary command with their own auth. So the two
17
+ * commands below are allowlisted precisely because this file declares them, and
18
+ * for no other message type.
19
+ */
20
+ export const CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE = 'channel_discord.ai_reply_proposal'
21
+
22
+ export const CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_ACTION_ID = 'approve_send'
23
+ export const CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_ACTION_ID = 'dismiss'
24
+
25
+ const proposalActions: MessageAction[] = [
26
+ {
27
+ id: CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_ACTION_ID,
28
+ label: 'Approve and send',
29
+ labelKey: 'channel_discord.aiProposal.actions.approve',
30
+ variant: 'default',
31
+ icon: 'send',
32
+ commandId: CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
33
+ isTerminal: true,
34
+ // Sending posts publicly into a Discord server on the tenant's behalf, so the
35
+ // operator confirms once before it leaves the building. No `confirmMessage`:
36
+ // the inbox renders that field verbatim (`message-detail/panels/dialogs.tsx`),
37
+ // so a translation key would leak into the dialog — omitting it falls back to
38
+ // the module's own localized confirmation copy.
39
+ confirmRequired: true,
40
+ },
41
+ {
42
+ id: CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_ACTION_ID,
43
+ label: 'Dismiss',
44
+ labelKey: 'channel_discord.aiProposal.actions.dismiss',
45
+ variant: 'outline',
46
+ icon: 'x',
47
+ commandId: CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,
48
+ isTerminal: true,
49
+ },
50
+ ]
51
+
52
+ export const messageTypes: MessageTypeDefinition[] = [
53
+ {
54
+ type: CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE,
55
+ module: 'channel_discord',
56
+ labelKey: 'channel_discord.aiProposal.messageType',
57
+ icon: 'bot',
58
+ color: 'violet',
59
+ ui: {
60
+ listItemComponent: 'messages.default.listItem',
61
+ contentComponent: 'messages.default.content',
62
+ actionsComponent: 'messages.default.actions',
63
+ },
64
+ // Replying to the proposal itself would go nowhere — the conversation lives
65
+ // on the Discord thread the proposal points at, not on this internal note.
66
+ allowReply: false,
67
+ allowForward: true,
68
+ defaultActions: proposalActions,
69
+ // A week-old proposal is stale: the person on Discord has moved on and the
70
+ // draft was written against a conversation that has since changed.
71
+ actionsExpireAfterHours: 168,
72
+ },
73
+ ]
74
+
75
+ export default messageTypes
@@ -0,0 +1,78 @@
1
+ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
+ import {
3
+ hasChannelAdapter,
4
+ registerChannelAdapter,
5
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
6
+ import { createCredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'
7
+ import { createIntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
8
+ import { createLogger } from '@open-mercato/shared/lib/logger'
9
+ import { getDiscordChannelAdapter } from './lib/adapter'
10
+ import { applyDiscordEnvPreset, CHANNEL_DISCORD_INTEGRATION_ID } from './lib/preset'
11
+
12
+ const logger = createLogger('channel_discord').child({ component: 'setup' })
13
+
14
+ /**
15
+ * Register the Discord `ChannelAdapter` exactly once per process at import time.
16
+ * The registry is process-wide, so we guard with `hasChannelAdapter` to silence
17
+ * the duplicate-providerKey error on dev-mode HMR + repeated test imports.
18
+ *
19
+ * Env presets (`OM_CHANNEL_DISCORD_*`, see `lib/preset.ts` and `.env.example`)
20
+ * are optional operator conveniences applied on tenant creation and rerunnable
21
+ * via `yarn mercato channel_discord configure-from-env`; the bot is normally
22
+ * connected via the credential connect flow (`/backend/integrations`). The
23
+ * gateway worker honours `OM_CHANNEL_DISCORD_GATEWAY_DISABLED` to skip the
24
+ * socket in CI / send-only deployments.
25
+ */
26
+ function ensureDiscordAdapterRegistered(): void {
27
+ if (hasChannelAdapter('discord')) return
28
+ registerChannelAdapter(getDiscordChannelAdapter())
29
+ }
30
+
31
+ ensureDiscordAdapterRegistered()
32
+
33
+ export const setup: ModuleSetupConfig = {
34
+ defaultRoleFeatures: {
35
+ // `ai_auto_reply.run` is granted here so a tenant that creates a real
36
+ // channel-bot user and puts it in an admin role gets a principal that can
37
+ // invoke the auto-reply agent. Deployments without such a user do not need
38
+ // the grant at all — the provider's service principal carries it in code
39
+ // (`lib/ai-service-principal.ts`).
40
+ superadmin: [
41
+ 'channel_discord.view',
42
+ 'channel_discord.configure',
43
+ 'channel_discord.ai_auto_reply.run',
44
+ ],
45
+ admin: [
46
+ 'channel_discord.view',
47
+ 'channel_discord.configure',
48
+ 'channel_discord.ai_auto_reply.run',
49
+ ],
50
+ },
51
+ async onTenantCreated({ em, organizationId, tenantId }) {
52
+ ensureDiscordAdapterRegistered()
53
+
54
+ const integrationLogService = createIntegrationLogService(em)
55
+ try {
56
+ await applyDiscordEnvPreset({
57
+ credentialsService: createCredentialsService(em),
58
+ integrationLogService,
59
+ scope: { tenantId, organizationId },
60
+ })
61
+ } catch (error) {
62
+ const message = error instanceof Error ? error.message : 'Unknown Discord preset error'
63
+ try {
64
+ await integrationLogService
65
+ .scoped(CHANNEL_DISCORD_INTEGRATION_ID, { tenantId, organizationId })
66
+ .error(`Failed to apply Discord env preset during tenant setup: ${message}`)
67
+ } catch (logError) {
68
+ const logMessage = logError instanceof Error ? logError.message : 'Unknown integration log error'
69
+ logger.error('Failed to apply env preset during tenant setup; persisting to integration logs also failed', {
70
+ presetError: message,
71
+ logError: logMessage,
72
+ })
73
+ }
74
+ }
75
+ },
76
+ }
77
+
78
+ export default setup