@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,299 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { AwilixContainer } from 'awilix'
4
+ import type { EntityManager } from '@mikro-orm/postgresql'
5
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
+ import type { CommandBus } from '@open-mercato/shared/lib/commands'
8
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
9
+ import { validateRouteMutationGuard } from '@open-mercato/core/modules/communication_channels/lib/route-mutation-guard'
10
+ import { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from '../../../../ai-agents'
11
+ import {
12
+ CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,
13
+ CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,
14
+ discordAiAutoReplySettingsSchema,
15
+ type UpdateAiAutoReplyInput,
16
+ type UpdateAiAutoReplyResult,
17
+ } from '../../../../commands/update-ai-auto-reply'
18
+ import {
19
+ findDiscordEligibleAgent,
20
+ listDiscordEligibleAgents,
21
+ missingAgentFeatures,
22
+ type AgentInvokingPrincipal,
23
+ } from '../../../../lib/ai-agent-directory'
24
+ import { CHANNEL_DISCORD_CONFIGURE_FEATURE, CHANNEL_DISCORD_VIEW_FEATURE } from '../../../../lib/ai-features'
25
+ import { resolveDiscordAiPrincipal } from '../../../../lib/ai-service-principal'
26
+ import { loadDiscordChannelForRequest } from '../../../../lib/channel-access'
27
+ import { discordChannelStateSchema } from '../../../../lib/credentials'
28
+
29
+ export const metadata = {
30
+ GET: {
31
+ requireAuth: true,
32
+ requireFeatures: [CHANNEL_DISCORD_VIEW_FEATURE],
33
+ },
34
+ PUT: {
35
+ requireAuth: true,
36
+ requireFeatures: [CHANNEL_DISCORD_CONFIGURE_FEATURE],
37
+ },
38
+ }
39
+
40
+ type RouteContext = {
41
+ params: Promise<{ id: string }> | { id: string }
42
+ }
43
+
44
+ function fieldError(field: string, message: string, extra?: Record<string, unknown>): Response {
45
+ return NextResponse.json(
46
+ { error: message, fieldErrors: { [field]: message }, ...extra },
47
+ { status: 400 },
48
+ )
49
+ }
50
+
51
+ /**
52
+ * The identity the auto-reply subscriber will run the chosen agent under, so both
53
+ * methods can answer "would the runtime accept this?" with the same grants the
54
+ * subscriber will present — the tenant's channel-bot user when it exists, the
55
+ * provider service principal otherwise.
56
+ */
57
+ async function loadInvokingPrincipal(
58
+ container: AwilixContainer,
59
+ scope: { tenantId: string; organizationId: string | null },
60
+ ): Promise<AgentInvokingPrincipal> {
61
+ const em = (container.resolve('em') as EntityManager).fork()
62
+ return resolveDiscordAiPrincipal({
63
+ em,
64
+ resolver: { resolve: <T,>(name: string): T => container.resolve(name) as T },
65
+ scope,
66
+ })
67
+ }
68
+
69
+ /**
70
+ * Read a Discord channel's AI auto-reply settings plus everything the settings
71
+ * form needs to render itself: the eligible agents, whether the optional AI peer
72
+ * is installed at all, and the channel's `updatedAt` so the form can attach the
73
+ * optimistic-lock header on save.
74
+ */
75
+ export async function GET(req: Request, context: RouteContext): Promise<Response> {
76
+ const { id } = await context.params
77
+ if (!z.string().uuid().safeParse(id).success) {
78
+ return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })
79
+ }
80
+
81
+ const auth = await getAuthFromRequest(req)
82
+ if (!auth?.sub || !auth?.tenantId) {
83
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
84
+ }
85
+
86
+ const container = await createRequestContainer()
87
+ const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'read' })
88
+ if ('response' in access) return access.response
89
+ const { channel } = access
90
+
91
+ const state = discordChannelStateSchema.safeParse(channel.channelState ?? {})
92
+ const directory = await listDiscordEligibleAgents()
93
+
94
+ // Every offered agent carries whether the auto-reply principal could actually
95
+ // invoke it. Without this the picker offers agents from other modules that the
96
+ // runtime will refuse, and the refusal only ever happens later in a background
97
+ // subscriber — so the operator sees "Auto-reply on" and silence.
98
+ const principal = directory.available
99
+ ? await loadInvokingPrincipal(container, {
100
+ tenantId: auth.tenantId as string,
101
+ organizationId: access.rbacOrganizationId,
102
+ })
103
+ : null
104
+ const agents = directory.available && principal
105
+ ? directory.agents.map((agent) => {
106
+ const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)
107
+ return { ...agent, invocable: missingFeatures.length === 0, missingFeatures }
108
+ })
109
+ : []
110
+
111
+ return NextResponse.json({
112
+ id: channel.id,
113
+ channelId: channel.id,
114
+ displayName: channel.displayName,
115
+ updatedAt: channel.updatedAt ? channel.updatedAt.toISOString() : null,
116
+ aiAutoReplyEnabled: state.success ? Boolean(state.data.aiAutoReplyEnabled) : false,
117
+ aiAgentId: (state.success ? state.data.aiAgentId : undefined) ?? null,
118
+ // Why the last attempt produced nothing, so an armed channel that answers
119
+ // nothing says so instead of looking healthy.
120
+ aiAutoReplyLastError: (state.success ? state.data.aiAutoReplyLastError : undefined) ?? null,
121
+ aiAutoReplyLastErrorAt: (state.success ? state.data.aiAutoReplyLastErrorAt : undefined) ?? null,
122
+ defaultAgentId: CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID,
123
+ aiAvailable: directory.available,
124
+ agents,
125
+ })
126
+ }
127
+
128
+ /**
129
+ * Write a Discord channel's AI auto-reply settings — the configuration path the
130
+ * subscriber was missing (issue #4778).
131
+ *
132
+ * The route is deliberately strict about *enabling*: it refuses to arm a channel
133
+ * whose AI peer is absent, one pointed at an agent the runtime would reject on
134
+ * shape, and one pointed at an agent the auto-reply principal is not authorized
135
+ * to invoke. All three would store a setting that can only fail later, inside a
136
+ * background subscriber where nobody sees the error — which is precisely how the
137
+ * feature ended up dormant in the first place. The three checks mirror, in order,
138
+ * what `runAiAgentObject` → `checkAgentPolicy` will ask at run time.
139
+ */
140
+ export async function PUT(req: Request, context: RouteContext): Promise<Response> {
141
+ const { id } = await context.params
142
+ if (!z.string().uuid().safeParse(id).success) {
143
+ return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })
144
+ }
145
+
146
+ const auth = await getAuthFromRequest(req)
147
+ if (!auth?.sub || !auth?.tenantId) {
148
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
149
+ }
150
+ const organizationId = (auth as { orgId?: string | null }).orgId ?? null
151
+
152
+ const rawBody = await req.json().catch(() => null)
153
+ if (rawBody == null || typeof rawBody !== 'object') {
154
+ return NextResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 })
155
+ }
156
+
157
+ const parsed = discordAiAutoReplySettingsSchema.safeParse(rawBody)
158
+ if (!parsed.success) {
159
+ const issue = parsed.error.issues[0]
160
+ const field = issue?.path.join('.') || 'aiAutoReplyEnabled'
161
+ return fieldError(field, issue?.message ?? 'Invalid AI auto-reply settings')
162
+ }
163
+
164
+ const container = await createRequestContainer()
165
+ // Authorize before the guard so a caller who may not touch this channel gets a
166
+ // masked 404 rather than a guard verdict about a record they cannot see. It
167
+ // also comes before the arming checks below, which need the channel's resolved
168
+ // organization to build the principal they judge the agent against.
169
+ const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'manage' })
170
+ if ('response' in access) return access.response
171
+
172
+ if (parsed.data.aiAutoReplyEnabled) {
173
+ const agentId = parsed.data.aiAgentId as string
174
+ const agent = await findDiscordEligibleAgent(agentId)
175
+ if (!agent) {
176
+ // No eligible agent under this id: either the optional AI peer is absent, or
177
+ // the agent is not object-mode and `runAiAgentObject` would reject it.
178
+ const directory = await listDiscordEligibleAgents()
179
+ return directory.available
180
+ ? fieldError('aiAgentId', 'channel_discord.aiAutoReply.errors.agentNotEligible')
181
+ : fieldError('aiAutoReplyEnabled', 'channel_discord.aiAutoReply.errors.aiUnavailable')
182
+ }
183
+
184
+ // Shape is not authorization. `checkAgentPolicy` enforces the agent's
185
+ // `requiredFeatures` against the auto-reply principal at run time, so arming a
186
+ // channel against an agent that principal cannot invoke stores a setting that
187
+ // can only fail later — inside a background subscriber, where the operator
188
+ // never sees it. Refuse here, naming the grants the tenant's channel-bot user
189
+ // is missing, rather than accepting a channel that would answer nothing.
190
+ const principal = await loadInvokingPrincipal(container, {
191
+ tenantId: auth.tenantId as string,
192
+ organizationId: access.rbacOrganizationId,
193
+ })
194
+ const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)
195
+ if (missingFeatures.length > 0) {
196
+ return fieldError(
197
+ 'aiAgentId',
198
+ 'channel_discord.aiAutoReply.errors.agentFeaturesMissing',
199
+ { missingFeatures },
200
+ )
201
+ }
202
+ }
203
+
204
+ const guard = await validateRouteMutationGuard({
205
+ container,
206
+ req,
207
+ auth,
208
+ input: {
209
+ resourceKind: CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,
210
+ resourceId: id,
211
+ operation: 'update',
212
+ mutationPayload: { ...parsed.data },
213
+ },
214
+ })
215
+ if ('response' in guard) return guard.response
216
+
217
+ const commandBus = container.resolve('commandBus') as CommandBus
218
+ const input: UpdateAiAutoReplyInput = {
219
+ channelId: id,
220
+ settings: parsed.data,
221
+ scope: {
222
+ tenantId: auth.tenantId as string,
223
+ organizationId: access.rbacOrganizationId,
224
+ organizationIds: access.organizationIds,
225
+ },
226
+ }
227
+
228
+ let result: UpdateAiAutoReplyResult
229
+ try {
230
+ const executed = await commandBus.execute<UpdateAiAutoReplyInput, UpdateAiAutoReplyResult>(
231
+ CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,
232
+ {
233
+ input,
234
+ ctx: {
235
+ container,
236
+ auth: auth as never,
237
+ organizationScope: null,
238
+ selectedOrganizationId: organizationId,
239
+ organizationIds: organizationId ? [organizationId] : null,
240
+ request: req,
241
+ },
242
+ },
243
+ )
244
+ result = executed.result
245
+ } catch (err) {
246
+ // The optimistic-lock guard throws the shared structured 409 so the form's
247
+ // conflict bar renders the same way it does anywhere else in the product.
248
+ if (isCrudHttpError(err)) {
249
+ return NextResponse.json(err.body, { status: err.status })
250
+ }
251
+ throw err
252
+ }
253
+
254
+ if (result.status === 'not_found') {
255
+ return NextResponse.json({ error: 'Channel not found' }, { status: 404 })
256
+ }
257
+
258
+ await guard.afterSuccess()
259
+ return NextResponse.json(
260
+ {
261
+ channelId: result.channelId,
262
+ aiAutoReplyEnabled: result.aiAutoReplyEnabled,
263
+ aiAgentId: result.aiAgentId,
264
+ updatedAt: result.updatedAt,
265
+ },
266
+ { status: 200 },
267
+ )
268
+ }
269
+
270
+ export const openApi = {
271
+ tags: ['CommunicationChannels'],
272
+ methods: {
273
+ GET: {
274
+ summary: "Read a Discord channel's AI auto-reply configuration",
275
+ tags: ['CommunicationChannels'],
276
+ responses: [
277
+ { status: 200, description: 'Current settings plus the agents this channel may be pointed at' },
278
+ { status: 400, description: 'Invalid channel id' },
279
+ { status: 401, description: 'Unauthorized' },
280
+ { status: 404, description: 'Channel not found, not a Discord channel in scope, or not the caller’s to see' },
281
+ ],
282
+ },
283
+ PUT: {
284
+ summary: "Update a Discord channel's AI auto-reply configuration",
285
+ tags: ['CommunicationChannels'],
286
+ responses: [
287
+ { status: 200, description: 'Settings stored' },
288
+ {
289
+ status: 400,
290
+ description:
291
+ 'Invalid payload, AI module absent, agent not eligible, or the auto-reply principal lacks the agent’s required features',
292
+ },
293
+ { status: 401, description: 'Unauthorized' },
294
+ { status: 404, description: 'Channel not found, or not a Discord channel in this scope' },
295
+ { status: 409, description: 'The channel changed since the form was loaded' },
296
+ ],
297
+ },
298
+ },
299
+ }
@@ -0,0 +1,242 @@
1
+ import { NextResponse } from 'next/server'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
+ import { createLogger } from '@open-mercato/shared/lib/logger'
7
+ import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
8
+ import { discordCredentialsSchema } from '../../lib/credentials'
9
+ import { DISCORD_MESSAGE_FLAG_EPHEMERAL } from '../../lib/discord-rest'
10
+ import type { DispatchableInteraction } from '../../lib/interactions-dispatch'
11
+ import {
12
+ DEFAULT_INTERACTION_MESSAGES,
13
+ resolveDiscordInteraction,
14
+ screenInteractionRequest,
15
+ type InteractionCandidate,
16
+ type InteractionCandidateFilter,
17
+ } from '../../lib/interactions-handler'
18
+ import {
19
+ getInteractionDispatchQueue,
20
+ type InteractionDispatchJobPayload,
21
+ } from '../../lib/interactions-queue'
22
+ import { DiscordInteractionResponseType } from '../../lib/interactions-verify'
23
+
24
+ const logger = createLogger('channel_discord').child({ component: 'interactions-route' })
25
+
26
+ /**
27
+ * Discord Interactions endpoint (slash commands, buttons, PING handshake).
28
+ *
29
+ * This is a **provider-owned** signed route — the resolution to the spec's one
30
+ * "under negotiation" hub touch-point. Discord requires a *synchronous* PONG
31
+ * (`{ type: 1 }`) on the initial PING, which the hub's generic
32
+ * `api/post/webhook/[provider]` route cannot return (it 202-acks + enqueues). By
33
+ * shipping this route from the provider package we serve the handshake without
34
+ * changing the hub contract. Operators set the Interactions Endpoint URL to
35
+ * `/api/channel_discord/interactions`.
36
+ *
37
+ * Auth model: unauthenticated at the platform layer — Ed25519 signature
38
+ * verification IS the auth, and it is fail-closed (a tampered/missing signature
39
+ * verifies against no candidate channel → 401).
40
+ *
41
+ * Timing model: Discord closes an interaction that is not answered within three
42
+ * seconds, so a verified slash command / component press is answered with a
43
+ * DEFERRED acknowledgement and handed to `workers/discord-interactions.ts`,
44
+ * which does the two slow halves — writing the interaction into the hub's
45
+ * inbound queue and replacing the "thinking…" placeholder with a real message.
46
+ * The hand-off happens BEFORE the ack is returned, so a queue that cannot accept
47
+ * the job downgrades the response to a visible error instead of promising a
48
+ * follow-up nothing will send.
49
+ */
50
+ export const metadata = {
51
+ // Pinned explicitly: this URL is operator-facing — it goes into the Discord
52
+ // application's Interactions Endpoint field — so it is part of this route's
53
+ // contract rather than a by-product of where the file happens to sit. It is
54
+ // the same path the folder derives, so the pin documents the contract instead
55
+ // of overriding it.
56
+ path: '/channel_discord/interactions',
57
+ POST: {
58
+ requireAuth: false,
59
+ // Unauthenticated by design. Unsigned traffic is now rejected before any
60
+ // candidate is loaded, so this bounds the residual case: a caller who does
61
+ // present well-formed, fresh headers and drives the narrowed candidate load
62
+ // repeatedly before the signature gate rejects them.
63
+ rateLimit: { points: 120, duration: 60, keyPrefix: 'discord_interactions' },
64
+ },
65
+ }
66
+
67
+ export async function POST(req: Request): Promise<Response> {
68
+ const rawBody = await req.text()
69
+ const signatureHex = req.headers.get('x-signature-ed25519')
70
+ const timestamp = req.headers.get('x-signature-timestamp')
71
+
72
+ // Screened here as well as inside `resolveDiscordInteraction` so that unsigned,
73
+ // malformed or stale traffic returns before the dictionary load below — this
74
+ // route is unauthenticated, so everything decidable from the request alone
75
+ // stays ahead of every other cost.
76
+ const screened = screenInteractionRequest({ signatureHex, timestamp })
77
+ if (screened) return NextResponse.json(screened.body, { status: screened.status })
78
+
79
+ const { t } = await resolveTranslations()
80
+
81
+ const result = await resolveDiscordInteraction({
82
+ rawBody,
83
+ signatureHex,
84
+ timestamp,
85
+ loadCandidates: loadInteractionCandidates,
86
+ messages: {
87
+ notDispatchable: t(
88
+ 'channel_discord.interactions.notDispatchable',
89
+ DEFAULT_INTERACTION_MESSAGES.notDispatchable,
90
+ ),
91
+ unsupported: t('channel_discord.interactions.unsupported', DEFAULT_INTERACTION_MESSAGES.unsupported),
92
+ },
93
+ })
94
+
95
+ // A deferred acknowledgement is a promise of a follow-up. Enqueue it BEFORE
96
+ // answering: if the hand-off fails the user gets a visible error instead of a
97
+ // "thinking…" state nothing will ever replace.
98
+ if (result.dispatch && result.matchedChannel) {
99
+ const enqueued = await enqueueInteractionDispatch(result.dispatch, result.matchedChannel)
100
+ if (!enqueued) {
101
+ return NextResponse.json(
102
+ {
103
+ type: DiscordInteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
104
+ data: {
105
+ content: t(
106
+ 'channel_discord.interactions.dispatchFailed',
107
+ 'Open Mercato could not record this interaction right now. Please try again in a moment.',
108
+ ),
109
+ flags: DISCORD_MESSAGE_FLAG_EPHEMERAL,
110
+ },
111
+ },
112
+ { status: 200 },
113
+ )
114
+ }
115
+ }
116
+
117
+ return NextResponse.json(result.body, { status: result.status })
118
+ }
119
+
120
+ /**
121
+ * Hand the verified interaction to the dispatch worker. Returns `false` when the
122
+ * job could not be queued at all, which is the one case where the deferred ack
123
+ * must be downgraded to a visible reply.
124
+ */
125
+ async function enqueueInteractionDispatch(
126
+ dispatch: DispatchableInteraction,
127
+ channel: InteractionCandidate,
128
+ ): Promise<boolean> {
129
+ const payload: InteractionDispatchJobPayload = {
130
+ channelId: channel.channelId,
131
+ channelType: channel.channelType,
132
+ tenantId: channel.tenantId,
133
+ organizationId: channel.organizationId,
134
+ credentialScope: channel.credentialScope,
135
+ interaction: dispatch,
136
+ }
137
+ try {
138
+ await getInteractionDispatchQueue().enqueue(payload as unknown as Record<string, unknown>)
139
+ return true
140
+ } catch (err) {
141
+ logger.error('failed to enqueue discord interaction dispatch', { err, channelId: channel.channelId })
142
+ return false
143
+ }
144
+ }
145
+
146
+ type CredentialsServiceLike = {
147
+ resolve: (
148
+ integrationId: string,
149
+ scope: { organizationId: string; tenantId: string; userId?: string | null },
150
+ ) => Promise<Record<string, unknown> | null>
151
+ }
152
+
153
+ async function loadInteractionCandidates(
154
+ filter: InteractionCandidateFilter,
155
+ ): Promise<InteractionCandidate[]> {
156
+ const container = await createRequestContainer()
157
+ const em = (container.resolve('em') as EntityManager).fork()
158
+
159
+ let credentialsService: CredentialsServiceLike | null = null
160
+ try {
161
+ credentialsService = container.resolve<CredentialsServiceLike>('integrationCredentialsService')
162
+ } catch {
163
+ credentialsService = null
164
+ }
165
+ if (!credentialsService) return []
166
+
167
+ const candidates: InteractionCandidate[] = []
168
+ try {
169
+ const rows = (await findWithDecryption(em, CommunicationChannel, {
170
+ providerKey: 'discord',
171
+ isActive: true,
172
+ deletedAt: null,
173
+ })) as CommunicationChannel[]
174
+
175
+ // Credentials are resolved per (tenant, organization, user) scope, and a
176
+ // tenant's Discord channels usually share one — cache within this request so
177
+ // N channel rows do not become N decrypts of the same credential bag.
178
+ const resolvedByScope = new Map<string, Record<string, unknown> | null>()
179
+
180
+ for (const channel of rows) {
181
+ if (!channel.credentialsRef) continue
182
+ const organizationId = channel.organizationId ?? channel.tenantId
183
+ const userId = channel.userId ?? null
184
+ const scopeKey = `${channel.tenantId}|${organizationId}|${userId ?? ''}`
185
+ if (!resolvedByScope.has(scopeKey)) {
186
+ try {
187
+ resolvedByScope.set(
188
+ scopeKey,
189
+ await credentialsService.resolve('channel_discord', {
190
+ tenantId: channel.tenantId,
191
+ organizationId,
192
+ userId,
193
+ }),
194
+ )
195
+ } catch {
196
+ resolvedByScope.set(scopeKey, null)
197
+ }
198
+ }
199
+ const parsed = discordCredentialsSchema.safeParse(resolvedByScope.get(scopeKey) ?? {})
200
+ if (!parsed.success) continue
201
+ // Narrowing only — the signature still decides. A body claiming an
202
+ // application nobody here owns simply verifies against nothing.
203
+ if (filter.applicationId && parsed.data.applicationId !== filter.applicationId) continue
204
+ candidates.push({
205
+ channelId: channel.id,
206
+ channelType: channel.channelType,
207
+ tenantId: channel.tenantId,
208
+ organizationId: channel.organizationId ?? null,
209
+ publicKey: parsed.data.publicKey,
210
+ applicationId: parsed.data.applicationId,
211
+ // The exact scope this row's credentials resolved under, so the worker
212
+ // re-resolves the same bag instead of guessing — and no token travels.
213
+ credentialScope: { tenantId: channel.tenantId, organizationId, userId },
214
+ })
215
+ }
216
+ } catch (err) {
217
+ logger.warn('failed to load discord interaction candidates', { err })
218
+ return []
219
+ }
220
+
221
+ return candidates
222
+ }
223
+
224
+ export const openApi = {
225
+ tags: ['ChannelDiscord'],
226
+ summary: 'Discord Interactions endpoint (slash commands, buttons, PING handshake)',
227
+ methods: {
228
+ POST: {
229
+ summary: 'Verify (Ed25519, fail-closed) and dispatch a Discord interaction',
230
+ tags: ['ChannelDiscord'],
231
+ responses: [
232
+ {
233
+ status: 200,
234
+ description:
235
+ 'Verified interaction — PONG for the handshake, a deferred ack for a dispatched slash command / component / modal submission, an empty autocomplete result, or an ephemeral message when the interaction cannot be dispatched',
236
+ },
237
+ { status: 400, description: 'Verified but malformed interaction body' },
238
+ { status: 401, description: 'Signature verification failed against every candidate channel, or the signed timestamp is outside the replay window' },
239
+ ],
240
+ },
241
+ },
242
+ }
@@ -0,0 +1,18 @@
1
+ import type { PageMetadata } from '@open-mercato/shared/modules/registry'
2
+ import { CHANNEL_DISCORD_CONFIGURE_FEATURE } from '../../../../../lib/ai-features'
3
+
4
+ /**
5
+ * Settings surface for one Discord channel's AI auto-reply. Guarded by the same
6
+ * feature that gates connecting the bot — arming an agent to answer strangers on
7
+ * the tenant's behalf is a configuration act, not a viewing one — and kept out of
8
+ * the sidebar, because it is reached from the channel it configures.
9
+ */
10
+ export const metadata: PageMetadata = {
11
+ requireAuth: true,
12
+ requireFeatures: [CHANNEL_DISCORD_CONFIGURE_FEATURE],
13
+ titleKey: 'channel_discord.aiAutoReply.page.title',
14
+ title: 'Discord AI auto-reply',
15
+ navHidden: true,
16
+ }
17
+
18
+ export default metadata