@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,288 @@
1
+ import type { InboundProcessorPayload } from '@open-mercato/core/modules/communication_channels/workers/inbound-processor'
2
+ import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
3
+ import { DISCORD_MAX_BODY_LENGTH } from './capabilities'
4
+ import type { DiscordMessageObject, DiscordRestClient, DiscordUser } from './discord-rest'
5
+ import { buildInboundMessageJob, type GatewayChannelScope } from './gateway-bridge'
6
+ import { DiscordInteractionType, type ParsedInteraction } from './interactions-verify'
7
+
8
+ /**
9
+ * Everything the dispatch path needs from a verified interaction, extracted once
10
+ * so nothing downstream has to re-read the untrusted body.
11
+ *
12
+ * This shape crosses a queue boundary (the HTTP route must answer Discord within
13
+ * three seconds, so the hub write and the follow-up REST call happen in a
14
+ * worker), which is why it carries no credential: `token` is the interaction's
15
+ * own short-lived webhook credential and the bot token is re-resolved from the
16
+ * encrypted credential store on the other side.
17
+ */
18
+ export interface DispatchableInteraction {
19
+ /** Interaction snowflake — also the hub's `externalMessageId`, so redelivery dedups. */
20
+ id: string
21
+ /** Interaction webhook token. Valid for 15 minutes, single interaction. */
22
+ token: string
23
+ type: number
24
+ applicationId: string
25
+ /** Discord text channel the interaction happened in. */
26
+ discordChannelId: string
27
+ guildId?: string
28
+ user: DiscordUser
29
+ /** Slash-command name, for `APPLICATION_COMMAND` / autocomplete. */
30
+ commandName?: string
31
+ /** Component / modal `custom_id`, for `MESSAGE_COMPONENT` / `MODAL_SUBMIT`. */
32
+ customId?: string
33
+ /** Human-readable rendering of what the user did — the hub message body. */
34
+ content: string
35
+ /** ISO timestamp the hub message is stamped with. */
36
+ timestamp: string
37
+ }
38
+
39
+ /** The interaction types this provider turns into hub messages. */
40
+ export const DISPATCHABLE_INTERACTION_TYPES: readonly number[] = [
41
+ DiscordInteractionType.APPLICATION_COMMAND,
42
+ DiscordInteractionType.MESSAGE_COMPONENT,
43
+ DiscordInteractionType.MODAL_SUBMIT,
44
+ ]
45
+
46
+ export function isDispatchableInteractionType(type: number): boolean {
47
+ return DISPATCHABLE_INTERACTION_TYPES.includes(type)
48
+ }
49
+
50
+ function readString(source: Record<string, unknown> | undefined, key: string): string | undefined {
51
+ const value = source?.[key]
52
+ return typeof value === 'string' && value.length > 0 ? value : undefined
53
+ }
54
+
55
+ function readOptionValue(option: Record<string, unknown>): string {
56
+ const value = option.value
57
+ if (value === undefined || value === null) return ''
58
+ if (typeof value === 'object') return JSON.stringify(value)
59
+ return String(value)
60
+ }
61
+
62
+ /**
63
+ * Flatten a slash command's options into `name:value` pairs.
64
+ *
65
+ * Discord nests subcommands and subcommand groups as options whose own `options`
66
+ * array holds the real arguments, so a naive one-level read renders
67
+ * `/support ticket:` and loses everything the user typed. Recursion is bounded by
68
+ * Discord's own two-level nesting limit.
69
+ */
70
+ function renderCommandOptions(options: unknown): string[] {
71
+ if (!Array.isArray(options)) return []
72
+ const rendered: string[] = []
73
+ for (const entry of options) {
74
+ if (!entry || typeof entry !== 'object') continue
75
+ const option = entry as Record<string, unknown>
76
+ const name = readString(option, 'name')
77
+ if (!name) continue
78
+ const nested = renderCommandOptions(option.options)
79
+ if (nested.length > 0) {
80
+ rendered.push(name, ...nested)
81
+ continue
82
+ }
83
+ rendered.push(`${name}:${readOptionValue(option)}`)
84
+ }
85
+ return rendered
86
+ }
87
+
88
+ /** Values a select menu returned, or the fields a modal submitted. */
89
+ function renderComponentValues(data: Record<string, unknown> | undefined): string[] {
90
+ if (!data) return []
91
+ const values = data.values
92
+ if (Array.isArray(values)) return values.map((value) => String(value))
93
+
94
+ // Modal submissions nest one input per action row.
95
+ const rows = data.components
96
+ if (!Array.isArray(rows)) return []
97
+ const rendered: string[] = []
98
+ for (const row of rows) {
99
+ const children = (row as { components?: unknown } | null)?.components
100
+ if (!Array.isArray(children)) continue
101
+ for (const child of children) {
102
+ if (!child || typeof child !== 'object') continue
103
+ const field = child as Record<string, unknown>
104
+ const customId = readString(field, 'custom_id')
105
+ const value = readString(field, 'value')
106
+ if (!customId) continue
107
+ rendered.push(`${customId}:${value ?? ''}`)
108
+ }
109
+ }
110
+ return rendered
111
+ }
112
+
113
+ /**
114
+ * Render an interaction as the text a human reading the hub inbox needs.
115
+ *
116
+ * A slash command becomes `/support message:the printer is jammed`; a button or
117
+ * select becomes `[component] escalate` plus any selected values. The hub stores
118
+ * the full interaction object in `channelPayload`, so this is a readable summary
119
+ * rather than the record of truth.
120
+ */
121
+ export function renderInteractionContent(interaction: ParsedInteraction): string {
122
+ const data = interaction.data
123
+ const commandName = readString(data, 'name')
124
+ const customId = readString(data, 'custom_id')
125
+
126
+ let rendered: string
127
+ if (interaction.type === DiscordInteractionType.APPLICATION_COMMAND) {
128
+ rendered = [`/${commandName ?? 'command'}`, ...renderCommandOptions(data?.options)].join(' ')
129
+ } else if (interaction.type === DiscordInteractionType.MESSAGE_COMPONENT) {
130
+ rendered = [`[component] ${customId ?? 'unknown'}`, ...renderComponentValues(data)].join(' ')
131
+ } else if (interaction.type === DiscordInteractionType.MODAL_SUBMIT) {
132
+ rendered = [`[modal] ${customId ?? 'unknown'}`, ...renderComponentValues(data)].join(' ')
133
+ } else {
134
+ rendered = `[interaction ${interaction.type}]`
135
+ }
136
+
137
+ return rendered.length > DISCORD_MAX_BODY_LENGTH ? rendered.slice(0, DISCORD_MAX_BODY_LENGTH) : rendered
138
+ }
139
+
140
+ /**
141
+ * Narrow a verified interaction to the fields the dispatch needs, or `null` when
142
+ * the payload cannot be dispatched at all.
143
+ *
144
+ * Returning `null` is not a rejection of the *caller* — the signature already
145
+ * verified — it means Discord sent a shape this provider cannot turn into a hub
146
+ * message (no channel, no invoking user, no follow-up token). The route answers
147
+ * those with a visible message rather than a deferred ack it could never
148
+ * replace.
149
+ */
150
+ export function readDispatchableInteraction(
151
+ interaction: ParsedInteraction,
152
+ options?: { now?: Date },
153
+ ): DispatchableInteraction | null {
154
+ if (!isDispatchableInteractionType(interaction.type)) return null
155
+
156
+ const id = typeof interaction.id === 'string' && interaction.id.length > 0 ? interaction.id : null
157
+ const token = typeof interaction.token === 'string' && interaction.token.length > 0 ? interaction.token : null
158
+ const applicationId =
159
+ typeof interaction.application_id === 'string' && interaction.application_id.length > 0
160
+ ? interaction.application_id
161
+ : null
162
+ const discordChannelId =
163
+ typeof interaction.channel_id === 'string' && interaction.channel_id.length > 0 ? interaction.channel_id : null
164
+ // A guild interaction carries the invoker under `member.user`; a DM carries it
165
+ // directly under `user`.
166
+ const rawUser = interaction.member?.user ?? interaction.user
167
+ const userId = typeof rawUser?.id === 'string' && rawUser.id.length > 0 ? rawUser.id : null
168
+
169
+ if (!id || !token || !applicationId || !discordChannelId || !userId) return null
170
+
171
+ return {
172
+ id,
173
+ token,
174
+ type: interaction.type,
175
+ applicationId,
176
+ discordChannelId,
177
+ guildId: typeof interaction.guild_id === 'string' ? interaction.guild_id : undefined,
178
+ user: {
179
+ id: userId,
180
+ username: rawUser?.username ?? userId,
181
+ global_name: rawUser?.global_name ?? null,
182
+ },
183
+ commandName: readString(interaction.data, 'name'),
184
+ customId: readString(interaction.data, 'custom_id'),
185
+ content: renderInteractionContent(interaction),
186
+ timestamp: (options?.now ?? new Date()).toISOString(),
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Present the interaction as the raw Discord message object the hub's inbound
192
+ * path already understands.
193
+ *
194
+ * WHY A SYNTHESIZED MESSAGE: the hub contract stays untouched. The inbound
195
+ * processor calls `adapter.normalizeInbound` on whatever `raw` it is handed, so
196
+ * shaping the interaction like a message means a slash command lands in the same
197
+ * conversation, under the same tenant scope, with the same dedup key discipline
198
+ * as anything typed in the channel — no hub-side special case for interactions.
199
+ */
200
+ export function buildInteractionMessage(dispatch: DispatchableInteraction): DiscordMessageObject {
201
+ return {
202
+ id: dispatch.id,
203
+ channel_id: dispatch.discordChannelId,
204
+ guild_id: dispatch.guildId,
205
+ content: dispatch.content,
206
+ author: dispatch.user,
207
+ timestamp: dispatch.timestamp,
208
+ discord_interaction: {
209
+ id: dispatch.id,
210
+ type: dispatch.type,
211
+ applicationId: dispatch.applicationId,
212
+ commandName: dispatch.commandName,
213
+ customId: dispatch.customId,
214
+ },
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Build the hub inbound-processor job for a dispatchable interaction — the same
220
+ * job shape, on the same queue, that the gateway worker produces for a typed
221
+ * message. Returns `null` when the interaction was invoked by a bot (the
222
+ * feedback-loop guard `buildInboundMessageJob` owns).
223
+ */
224
+ export function buildInteractionInboundJob(input: {
225
+ dispatch: DispatchableInteraction
226
+ channel: GatewayChannelScope
227
+ botUserId?: string
228
+ }): InboundProcessorPayload | null {
229
+ return buildInboundMessageJob({
230
+ message: buildInteractionMessage(input.dispatch),
231
+ channel: input.channel,
232
+ botUserId: input.botUserId,
233
+ })
234
+ }
235
+
236
+ /**
237
+ * Copy shown to the user in Discord once the interaction has been recorded.
238
+ *
239
+ * Takes the translator rather than resolving one, so the pure builder stays
240
+ * usable from the worker, from the route and from a test with no module
241
+ * registry. Each key carries its English fallback: a worker process that never
242
+ * loaded the app dictionaries must still say something a human can read, not
243
+ * echo a translation key back into a Discord channel.
244
+ */
245
+ export function buildInteractionFollowUpContent(dispatch: DispatchableInteraction, t: TranslateFn): string {
246
+ if (dispatch.type === DiscordInteractionType.APPLICATION_COMMAND) {
247
+ return t(
248
+ 'channel_discord.interactions.commandReceived',
249
+ 'Received — `/{command}` is now in the Open Mercato inbox and someone will pick it up from there.',
250
+ { command: dispatch.commandName ?? '' },
251
+ )
252
+ }
253
+ return t(
254
+ 'channel_discord.interactions.componentReceived',
255
+ 'Received — your response is now in the Open Mercato inbox and someone will pick it up from there.',
256
+ )
257
+ }
258
+
259
+ /**
260
+ * Replace the deferred acknowledgement with a real message.
261
+ *
262
+ * ORDER MATTERS: editing the original response is what actually ends Discord's
263
+ * "thinking…" state, so it is tried first. A follow-up POST is the fallback for
264
+ * the case where the original response is already gone (Discord answers 404),
265
+ * which still gets the user a visible answer instead of a spinner that never
266
+ * resolves.
267
+ *
268
+ * Returns which call delivered the message so the worker can log it; throws only
269
+ * when neither did, letting the queue's retry policy have another go inside the
270
+ * interaction token's 15-minute lifetime.
271
+ */
272
+ export async function sendInteractionFollowUp(
273
+ client: DiscordRestClient,
274
+ input: { applicationId: string; interactionToken: string; content: string; ephemeral?: boolean },
275
+ ): Promise<'edited-original' | 'follow-up'> {
276
+ const body = { content: input.content, ephemeral: input.ephemeral }
277
+ try {
278
+ await client.editOriginalInteractionResponse(input.applicationId, input.interactionToken, body)
279
+ return 'edited-original'
280
+ } catch (editError) {
281
+ try {
282
+ await client.createInteractionFollowUp(input.applicationId, input.interactionToken, body)
283
+ return 'follow-up'
284
+ } catch {
285
+ throw editError
286
+ }
287
+ }
288
+ }
@@ -0,0 +1,267 @@
1
+ import { DISCORD_MESSAGE_FLAG_EPHEMERAL } from './discord-rest'
2
+ import {
3
+ isDispatchableInteractionType,
4
+ readDispatchableInteraction,
5
+ type DispatchableInteraction,
6
+ } from './interactions-dispatch'
7
+ import {
8
+ DiscordInteractionResponseType,
9
+ DiscordInteractionType,
10
+ hasVerifiableSignatureHeaders,
11
+ isSignatureTimestampFresh,
12
+ parseInteractionBody,
13
+ readInteractionApplicationId,
14
+ verifyDiscordSignature,
15
+ type TimestampFreshnessOptions,
16
+ } from './interactions-verify'
17
+
18
+ export interface InteractionCandidate {
19
+ channelId: string
20
+ /** Hub channel type, carried through so the dispatch job matches the gateway's. */
21
+ channelType: string
22
+ tenantId: string
23
+ organizationId: string | null
24
+ publicKey: string
25
+ /** Discord application this channel's bot belongs to; `null` when unknown. */
26
+ applicationId: string | null
27
+ /**
28
+ * Scope `integrationCredentialsService.resolve` must be called with to obtain
29
+ * this channel's bot token. Carried instead of the token itself so no
30
+ * credential ever reaches a queue payload.
31
+ */
32
+ credentialScope: { tenantId: string; organizationId: string; userId: string | null }
33
+ }
34
+
35
+ /**
36
+ * The two user-visible strings the synchronous handler can have to emit.
37
+ *
38
+ * They are passed in rather than translated here so the handler stays pure and
39
+ * synchronous — Discord gives the endpoint three seconds, and the caller already
40
+ * knows the locale.
41
+ */
42
+ export interface InteractionMessages {
43
+ /** Verified, but the payload carries no channel / user / token to dispatch. */
44
+ notDispatchable: string
45
+ /** Verified and well-formed, but this provider does not handle the type. */
46
+ unsupported: string
47
+ }
48
+
49
+ export const DEFAULT_INTERACTION_MESSAGES: InteractionMessages = {
50
+ notDispatchable:
51
+ 'This interaction is missing the channel or user context Open Mercato needs, so it was not recorded.',
52
+ unsupported: 'The Open Mercato Discord channel does not handle this interaction type.',
53
+ }
54
+
55
+ function ephemeralReply(content: string): Record<string, unknown> {
56
+ return {
57
+ type: DiscordInteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
58
+ data: { content, flags: DISCORD_MESSAGE_FLAG_EPHEMERAL },
59
+ }
60
+ }
61
+
62
+ /**
63
+ * What the candidate loader is allowed to narrow by. `applicationId` comes from
64
+ * the unverified request body, so it may only ever shrink the candidate set —
65
+ * see `readInteractionApplicationId`.
66
+ */
67
+ export interface InteractionCandidateFilter {
68
+ applicationId: string | null
69
+ }
70
+
71
+ export interface InteractionResult {
72
+ status: number
73
+ /** JSON body to return (Discord expects a response type for verified interactions). */
74
+ body: Record<string, unknown>
75
+ /** The channel the signature verified against (tenant-pinned). Null on reject. */
76
+ matchedChannel: InteractionCandidate | null
77
+ /**
78
+ * Work the caller MUST hand off when the body above is a deferred
79
+ * acknowledgement. Non-null exactly when the response promises a follow-up:
80
+ * ignoring it is what leaves the user staring at "thinking…" forever.
81
+ */
82
+ dispatch?: DispatchableInteraction | null
83
+ }
84
+
85
+ /**
86
+ * Request-only rejection gate, evaluated before anything tenant-scoped is loaded.
87
+ *
88
+ * WHY IT EXISTS: this endpoint is unauthenticated by design, so any caller can
89
+ * POST to it. Everything decidable from the request alone — a missing, malformed
90
+ * or wrong-length signature header, a missing or stale timestamp — is decided
91
+ * here, so that traffic never reaches the candidate loader and never costs a
92
+ * database round-trip or a credential decrypt.
93
+ *
94
+ * Returns the rejection to send, or `null` when the request is well-formed
95
+ * enough that candidates must be loaded to answer it. It is deliberately
96
+ * signature-*shape* only: whether the signature actually verifies still requires
97
+ * a public key, and that check stays where it was.
98
+ */
99
+ export function screenInteractionRequest(input: {
100
+ signatureHex: string | undefined | null
101
+ timestamp: string | undefined | null
102
+ freshness?: TimestampFreshnessOptions
103
+ }): InteractionResult | null {
104
+ const { signatureHex, timestamp, freshness } = input
105
+
106
+ if (!isSignatureTimestampFresh(timestamp, freshness)) {
107
+ return { status: 401, body: { error: 'stale_timestamp' }, matchedChannel: null }
108
+ }
109
+ if (!hasVerifiableSignatureHeaders(signatureHex, timestamp)) {
110
+ return { status: 401, body: { error: 'invalid_signature' }, matchedChannel: null }
111
+ }
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * Screen the request, then load only the candidates that could plausibly match
117
+ * it, then dispatch.
118
+ *
119
+ * This is the order the HTTP route must follow, expressed once and injectable so
120
+ * it can be tested without a database: `loadCandidates` is not called at all for
121
+ * a request the screening gate rejects, and when it is called it receives the
122
+ * `application_id` narrowing so the Ed25519 fan-out runs over the channels of
123
+ * one Discord application instead of every Discord channel in the installation.
124
+ *
125
+ * The narrowing is a NARROWING, never an authorization decision — the signature
126
+ * gate below is unchanged, so a body that claims someone else's `application_id`
127
+ * still fails unless it also carries that application's signature.
128
+ */
129
+ export async function resolveDiscordInteraction(input: {
130
+ rawBody: string
131
+ signatureHex: string | undefined | null
132
+ timestamp: string | undefined | null
133
+ loadCandidates: (filter: InteractionCandidateFilter) => Promise<InteractionCandidate[]>
134
+ freshness?: TimestampFreshnessOptions
135
+ messages?: InteractionMessages
136
+ }): Promise<InteractionResult> {
137
+ const { rawBody, signatureHex, timestamp, loadCandidates, freshness, messages } = input
138
+
139
+ const screened = screenInteractionRequest({ signatureHex, timestamp, freshness })
140
+ if (screened) return screened
141
+
142
+ const candidates = await loadCandidates({ applicationId: readInteractionApplicationId(rawBody) })
143
+ return handleDiscordInteraction({ rawBody, signatureHex, timestamp, candidates, freshness, messages })
144
+ }
145
+
146
+ /**
147
+ * Core Discord Interactions dispatch — pure and testable (no HTTP/DB).
148
+ *
149
+ * SECURITY (fail-closed): the request is rejected (401) unless its Ed25519
150
+ * signature verifies against exactly one candidate channel's public key. The
151
+ * matched channel pins the tenant, so one tenant's interaction never lands in
152
+ * another tenant's scope. A tampered / missing signature verifies against no
153
+ * candidate → 401, and no tenant-scoped work is done.
154
+ *
155
+ * REPLAY GUARD: the signed timestamp must be within
156
+ * `DISCORD_SIGNATURE_MAX_SKEW_SECONDS` of the server clock. The check runs
157
+ * BEFORE the per-candidate Ed25519 fan-out, so a replayed capture (still
158
+ * cryptographically valid forever) is rejected without any verify work.
159
+ *
160
+ * COST: this function only decides what to do with candidates it is handed.
161
+ * Callers that have to *load* those candidates first must run
162
+ * `screenInteractionRequest` before doing so — that is what keeps an unsigned or
163
+ * stale request genuinely constant-cost rather than merely cheap after the
164
+ * database has already been read (`resolveDiscordInteraction` does it for them).
165
+ *
166
+ * On the mandatory PING (type 1) handshake it returns `{ type: 1 }` (PONG) so
167
+ * Discord accepts the endpoint URL.
168
+ *
169
+ * Application commands, message components and modal submissions return a
170
+ * deferred acknowledgement TOGETHER WITH the `dispatch` the caller must hand to
171
+ * the interactions worker: that worker normalizes the interaction into the hub's
172
+ * existing inbound path and replaces the deferred ack with a real message. The
173
+ * two travel in one result on purpose — a deferred ack nobody follows up is the
174
+ * permanent "thinking…" state this endpoint used to produce.
175
+ *
176
+ * Autocomplete is answered synchronously with an empty choice list, the only
177
+ * response Discord accepts for it, and anything else gets a visible, ephemeral
178
+ * "not handled" reply rather than a promise this provider cannot keep.
179
+ */
180
+ export function handleDiscordInteraction(input: {
181
+ rawBody: string
182
+ signatureHex: string | undefined | null
183
+ timestamp: string | undefined | null
184
+ candidates: InteractionCandidate[]
185
+ freshness?: TimestampFreshnessOptions
186
+ messages?: InteractionMessages
187
+ }): InteractionResult {
188
+ const { rawBody, signatureHex, timestamp, candidates, freshness } = input
189
+ const messages = input.messages ?? DEFAULT_INTERACTION_MESSAGES
190
+
191
+ const screened = screenInteractionRequest({ signatureHex, timestamp, freshness })
192
+ if (screened) return screened
193
+
194
+ let matched: InteractionCandidate | null = null
195
+ for (const candidate of candidates) {
196
+ const ok = verifyDiscordSignature({
197
+ publicKeyHex: candidate.publicKey,
198
+ signatureHex,
199
+ timestamp,
200
+ rawBody,
201
+ })
202
+ if (ok) {
203
+ matched = candidate
204
+ break
205
+ }
206
+ }
207
+
208
+ if (!matched) {
209
+ // FAIL-CLOSED — never acknowledge an unverified interaction.
210
+ return { status: 401, body: { error: 'invalid_signature' }, matchedChannel: null }
211
+ }
212
+
213
+ const interaction = parseInteractionBody(rawBody)
214
+ if (!interaction) {
215
+ return { status: 400, body: { error: 'invalid_interaction' }, matchedChannel: matched }
216
+ }
217
+
218
+ if (interaction.type === DiscordInteractionType.PING) {
219
+ return {
220
+ status: 200,
221
+ body: { type: DiscordInteractionResponseType.PONG },
222
+ matchedChannel: matched,
223
+ }
224
+ }
225
+
226
+ if (isDispatchableInteractionType(interaction.type)) {
227
+ const dispatch = readDispatchableInteraction(interaction)
228
+ if (!dispatch) {
229
+ // Deferring here would be a promise we cannot keep: without the follow-up
230
+ // token or the channel there is nothing to edit the placeholder with.
231
+ return {
232
+ status: 200,
233
+ body: ephemeralReply(messages.notDispatchable),
234
+ matchedChannel: matched,
235
+ dispatch: null,
236
+ }
237
+ }
238
+ return {
239
+ status: 200,
240
+ body: { type: DiscordInteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE },
241
+ matchedChannel: matched,
242
+ dispatch,
243
+ }
244
+ }
245
+
246
+ if (interaction.type === DiscordInteractionType.APPLICATION_COMMAND_AUTOCOMPLETE) {
247
+ // Discord rejects a deferred ack for autocomplete. No command this provider
248
+ // registers declares an autocompleting option, so an empty list is the
249
+ // honest answer and it closes the request immediately.
250
+ return {
251
+ status: 200,
252
+ body: {
253
+ type: DiscordInteractionResponseType.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT,
254
+ data: { choices: [] },
255
+ },
256
+ matchedChannel: matched,
257
+ dispatch: null,
258
+ }
259
+ }
260
+
261
+ return {
262
+ status: 200,
263
+ body: ephemeralReply(messages.unsupported),
264
+ matchedChannel: matched,
265
+ dispatch: null,
266
+ }
267
+ }
@@ -0,0 +1,52 @@
1
+ import { createModuleQueue, type Queue } from '@open-mercato/queue'
2
+ import type { DispatchableInteraction } from './interactions-dispatch'
3
+
4
+ /**
5
+ * Queue carrying verified Discord interactions from the HTTP route to the
6
+ * dispatch worker.
7
+ *
8
+ * It lives here rather than in the worker so the route never imports a worker
9
+ * module: `workers/` is an auto-discovery path, and a route that pulled one in
10
+ * would drag the worker's dependency graph into the request bundle.
11
+ *
12
+ * Mirrors the hub's `getCommunicationChannelsQueue` — one memoized instance per
13
+ * name, so the route and the worker share it instead of constructing a queue per
14
+ * request.
15
+ */
16
+ export const CHANNEL_DISCORD_INTERACTIONS_QUEUE = 'channel_discord_interactions'
17
+
18
+ const INTERACTIONS_QUEUE_CONCURRENCY = 5
19
+
20
+ export type InteractionDispatchJobPayload = {
21
+ channelId: string
22
+ channelType: string
23
+ tenantId: string
24
+ organizationId: string | null
25
+ /**
26
+ * Scope the **bot token** is re-resolved with on the worker side. The bot token
27
+ * itself is deliberately absent, because the local queue strategy persists
28
+ * payloads to disk as plain JSON and that token is long-lived.
29
+ *
30
+ * This is not a blanket "no credential travels on this payload" guarantee, and
31
+ * reading it as one would be a mistake: `interaction.token` below IS a
32
+ * credential. It is Discord's interaction webhook token, and on its own — with
33
+ * no bot token at all — it can post as the application (the `auth === null`
34
+ * calls in `discord-rest.ts`), so it does land on disk under the local
35
+ * strategy. Discord expires it 15 minutes after the interaction, which bounds
36
+ * the exposure rather than removing it. `interactions-dispatch.ts` states the
37
+ * same constraint from the other side.
38
+ */
39
+ credentialScope: { tenantId: string; organizationId: string; userId: string | null }
40
+ interaction: DispatchableInteraction
41
+ }
42
+
43
+ let cachedQueue: Queue<Record<string, unknown>> | null = null
44
+
45
+ export function getInteractionDispatchQueue(): Queue<Record<string, unknown>> {
46
+ if (!cachedQueue) {
47
+ cachedQueue = createModuleQueue<Record<string, unknown>>(CHANNEL_DISCORD_INTERACTIONS_QUEUE, {
48
+ concurrency: INTERACTIONS_QUEUE_CONCURRENCY,
49
+ })
50
+ }
51
+ return cachedQueue
52
+ }