@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,99 @@
1
+ import { NextResponse } from 'next/server'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
7
+ import { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
8
+ import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
9
+ import { channelOrgScopeWhereFromFilter } from '@open-mercato/core/modules/communication_channels/lib/access-control'
10
+ import { CHANNEL_DISCORD_VIEW_FEATURE } from '../../../lib/ai-features'
11
+ import { discordChannelStateSchema } from '../../../lib/credentials'
12
+
13
+ /**
14
+ * Every Discord channel's AI auto-reply state in one call.
15
+ *
16
+ * The integration detail panel needs two booleans and an agent id per channel.
17
+ * Asking the per-channel settings route for them meant one request per channel
18
+ * *and* one full agent-registry load per request — 41 requests and 40 registry
19
+ * loads to render 40 rows on a page operators land on. Nothing about that panel
20
+ * needs the agent directory, so this route does not build one.
21
+ *
22
+ * The path is pinned in `metadata` rather than inferred from the file location,
23
+ * the way `api/interactions/route.ts` pins its own: the sibling settings route
24
+ * lives under `channels/[id]/`, and a collection endpoint that reads as a channel
25
+ * id (`/channels/ai-auto-reply`) is a URL waiting to be mistaken for one.
26
+ */
27
+ export const metadata = {
28
+ path: '/channel_discord/ai-auto-reply/channels',
29
+ GET: {
30
+ requireAuth: true,
31
+ requireFeatures: [CHANNEL_DISCORD_VIEW_FEATURE],
32
+ },
33
+ }
34
+
35
+ /**
36
+ * The panel lists a handful of bot channels; the cap is a guard against an
37
+ * unbounded scan, not a paging contract. `truncated` tells the client when it is
38
+ * looking at a partial list rather than letting it silently under-report.
39
+ */
40
+ const MAX_CHANNELS = 200
41
+
42
+ export async function GET(req: Request): Promise<Response> {
43
+ const auth = await getAuthFromRequest(req)
44
+ if (!auth?.sub || !auth?.tenantId) {
45
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
46
+ }
47
+
48
+ const container = await createRequestContainer()
49
+ const em = (container.resolve('em') as EntityManager).fork()
50
+ const scope = await resolveOrganizationScopeForRequest({ container, auth: auth as never, request: req })
51
+ const orgFilter = resolveOrganizationScopeFilter(scope, auth as never)
52
+
53
+ // Same scoping the hub's own channel list uses: a Discord bot channel is
54
+ // usually tenant-scoped (`organization_id IS NULL`), so an equality filter on
55
+ // the caller's selected organization would hide exactly the rows this panel is
56
+ // about (#5012). `userId: null` keeps personal mailboxes out, as the hub does.
57
+ const channels = await findWithDecryption(
58
+ em,
59
+ CommunicationChannel,
60
+ {
61
+ tenantId: auth.tenantId as string,
62
+ providerKey: 'discord',
63
+ ...channelOrgScopeWhereFromFilter(orgFilter),
64
+ userId: null,
65
+ deletedAt: null,
66
+ },
67
+ { limit: MAX_CHANNELS + 1, orderBy: { createdAt: 'desc' } },
68
+ { tenantId: auth.tenantId as string, organizationId: orgFilter.rbacOrganizationId },
69
+ )
70
+
71
+ const truncated = channels.length > MAX_CHANNELS
72
+ const items = (truncated ? channels.slice(0, MAX_CHANNELS) : channels).map((channel) => {
73
+ const state = discordChannelStateSchema.safeParse(channel.channelState ?? {})
74
+ return {
75
+ channelId: channel.id,
76
+ displayName: channel.displayName,
77
+ aiAutoReplyEnabled: state.success ? Boolean(state.data.aiAutoReplyEnabled) : false,
78
+ aiAgentId: (state.success ? state.data.aiAgentId : undefined) ?? null,
79
+ aiAutoReplyLastError: (state.success ? state.data.aiAutoReplyLastError : undefined) ?? null,
80
+ aiAutoReplyLastErrorAt: (state.success ? state.data.aiAutoReplyLastErrorAt : undefined) ?? null,
81
+ }
82
+ })
83
+
84
+ return NextResponse.json({ items, truncated })
85
+ }
86
+
87
+ export const openApi = {
88
+ tags: ['CommunicationChannels'],
89
+ methods: {
90
+ GET: {
91
+ summary: 'List the AI auto-reply state of every Discord channel in scope',
92
+ tags: ['CommunicationChannels'],
93
+ responses: [
94
+ { status: 200, description: 'One row per Discord channel the caller may see' },
95
+ { status: 401, description: 'Unauthorized' },
96
+ ],
97
+ },
98
+ },
99
+ }
@@ -0,0 +1,311 @@
1
+ /** @jest-environment node */
2
+
3
+ /**
4
+ * Arming contract for the AI auto-reply settings route (issue #4778).
5
+ *
6
+ * The route already refused an absent AI peer and a non-object-mode agent. Shape
7
+ * is not authorization, though: `runAiAgentObject` → `checkAgentPolicy` also runs
8
+ * the agent's `requiredFeatures` against the principal from
9
+ * `lib/ai-service-principal.ts`, which carries only the provider grant unless the
10
+ * tenant created a channel-bot user with a wider role. An agent that passes the
11
+ * shape check and fails the feature check stored a setting that could only fail
12
+ * later, in a background subscriber where nobody sees it — the channel read
13
+ * "Auto-reply on" and answered nothing, forever.
14
+ *
15
+ * `missingAgentFeatures` is deliberately NOT mocked here: the point of the test
16
+ * is that the route asks the platform's real feature-policy question, wildcard
17
+ * grants included.
18
+ */
19
+ const tenantId = '11111111-1111-4111-8111-111111111111'
20
+ const organizationId = '22222222-2222-4222-8222-222222222222'
21
+ const channelId = '33333333-3333-4333-8333-333333333333'
22
+ const userId = '44444444-4444-4444-8444-444444444444'
23
+
24
+ const PROVIDER_AGENT = {
25
+ id: 'channel_discord.auto_reply',
26
+ label: 'Discord auto-reply',
27
+ description: '',
28
+ requiredFeatures: ['channel_discord.ai_auto_reply.run'],
29
+ }
30
+ const FOREIGN_AGENT = {
31
+ id: 'customers.support',
32
+ label: 'Customer support',
33
+ description: '',
34
+ requiredFeatures: ['customers.view', 'customers.manage'],
35
+ }
36
+
37
+ const getAuthFromRequestMock = jest.fn()
38
+ const loadDiscordChannelForRequestMock = jest.fn()
39
+ const validateRouteMutationGuardMock = jest.fn()
40
+ const resolveDiscordAiPrincipalMock = jest.fn()
41
+ const listDiscordEligibleAgentsMock = jest.fn()
42
+ const commandBusExecuteMock = jest.fn()
43
+
44
+ const em = { fork: () => em }
45
+ const container = {
46
+ resolve: jest.fn((name: string) => {
47
+ if (name === 'em') return em
48
+ if (name === 'commandBus') return { execute: commandBusExecuteMock }
49
+ if (name === 'rbacService') throw new Error('not registered in this test')
50
+ throw new Error(`Unexpected container resolve: ${name}`)
51
+ }),
52
+ }
53
+
54
+ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
55
+ getAuthFromRequest: (...args: unknown[]) => getAuthFromRequestMock(...args),
56
+ }))
57
+
58
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
59
+ createRequestContainer: jest.fn(async () => container),
60
+ }))
61
+
62
+ jest.mock('@open-mercato/core/modules/communication_channels/lib/route-mutation-guard', () => ({
63
+ validateRouteMutationGuard: (...args: unknown[]) => validateRouteMutationGuardMock(...args),
64
+ }))
65
+
66
+ jest.mock('../../../../../lib/channel-access', () => ({
67
+ loadDiscordChannelForRequest: (...args: unknown[]) => loadDiscordChannelForRequestMock(...args),
68
+ }))
69
+
70
+ jest.mock('../../../../../lib/ai-service-principal', () => ({
71
+ resolveDiscordAiPrincipal: (...args: unknown[]) => resolveDiscordAiPrincipalMock(...args),
72
+ }))
73
+
74
+ // Only the registry lookup is stubbed; the feature comparison under test is real.
75
+ jest.mock('../../../../../lib/ai-agent-directory', () => {
76
+ const actual = jest.requireActual('../../../../../lib/ai-agent-directory')
77
+ return {
78
+ ...actual,
79
+ listDiscordEligibleAgents: (...args: unknown[]) => listDiscordEligibleAgentsMock(...args),
80
+ findDiscordEligibleAgent: async (agentId: string) => {
81
+ const directory = await listDiscordEligibleAgentsMock()
82
+ if (!directory.available) return null
83
+ return directory.agents.find((agent: { id: string }) => agent.id === agentId) ?? null
84
+ },
85
+ }
86
+ })
87
+
88
+ import { GET, PUT } from '../route'
89
+
90
+ const channel = {
91
+ id: channelId,
92
+ displayName: 'Discord bot',
93
+ updatedAt: new Date('2026-08-01T00:00:00.000Z'),
94
+ channelState: { aiAutoReplyEnabled: false },
95
+ }
96
+
97
+ function putRequest(body: unknown): Request {
98
+ return new Request(`http://localhost/api/channel_discord/channels/${channelId}/ai-auto-reply`, {
99
+ method: 'PUT',
100
+ body: JSON.stringify(body),
101
+ })
102
+ }
103
+
104
+ function getRequest(): Request {
105
+ return new Request(`http://localhost/api/channel_discord/channels/${channelId}/ai-auto-reply`)
106
+ }
107
+
108
+ const context = { params: { id: channelId } }
109
+
110
+ beforeEach(() => {
111
+ jest.clearAllMocks()
112
+ getAuthFromRequestMock.mockResolvedValue({ sub: userId, tenantId, orgId: organizationId })
113
+ loadDiscordChannelForRequestMock.mockResolvedValue({
114
+ channel,
115
+ organizationIds: [organizationId],
116
+ rbacOrganizationId: organizationId,
117
+ })
118
+ validateRouteMutationGuardMock.mockResolvedValue({ afterSuccess: jest.fn(async () => {}) })
119
+ listDiscordEligibleAgentsMock.mockResolvedValue({
120
+ available: true,
121
+ agents: [PROVIDER_AGENT, FOREIGN_AGENT],
122
+ })
123
+ // The default tenant: no channel-bot user, so the provider service principal.
124
+ resolveDiscordAiPrincipalMock.mockResolvedValue({
125
+ tenantId,
126
+ organizationId,
127
+ userId,
128
+ features: ['channel_discord.ai_auto_reply.run'],
129
+ isSuperAdmin: false,
130
+ source: 'provider_service_principal',
131
+ })
132
+ commandBusExecuteMock.mockResolvedValue({
133
+ result: {
134
+ status: 'updated',
135
+ channelId,
136
+ aiAutoReplyEnabled: true,
137
+ aiAgentId: PROVIDER_AGENT.id,
138
+ updatedAt: '2026-08-02T00:00:00.000Z',
139
+ },
140
+ })
141
+ })
142
+
143
+ describe('PUT /channel_discord/channels/{id}/ai-auto-reply — arming checks', () => {
144
+ it('refuses an agent whose required features the auto-reply principal lacks', async () => {
145
+ const response = await PUT(
146
+ putRequest({ aiAutoReplyEnabled: true, aiAgentId: FOREIGN_AGENT.id }),
147
+ context,
148
+ )
149
+
150
+ expect(response.status).toBe(400)
151
+ const body = await response.json()
152
+ expect(body.fieldErrors.aiAgentId).toBe('channel_discord.aiAutoReply.errors.agentFeaturesMissing')
153
+ // The operator has to be told WHICH grants are missing; a bare refusal sends
154
+ // them back to the same dead end.
155
+ expect(body.missingFeatures).toEqual(['customers.view', 'customers.manage'])
156
+ // The refusal happens before anything is stored.
157
+ expect(commandBusExecuteMock).not.toHaveBeenCalled()
158
+ })
159
+
160
+ it('arms the provider agent under the service principal with no operator setup', async () => {
161
+ const response = await PUT(
162
+ putRequest({ aiAutoReplyEnabled: true, aiAgentId: PROVIDER_AGENT.id }),
163
+ context,
164
+ )
165
+
166
+ expect(response.status).toBe(200)
167
+ expect(commandBusExecuteMock).toHaveBeenCalledTimes(1)
168
+ })
169
+
170
+ it('arms a foreign agent once the channel-bot user carries its features', async () => {
171
+ resolveDiscordAiPrincipalMock.mockResolvedValue({
172
+ tenantId,
173
+ organizationId,
174
+ userId,
175
+ features: ['channel_discord.ai_auto_reply.run', 'customers.view', 'customers.manage'],
176
+ isSuperAdmin: false,
177
+ source: 'channel_bot_user',
178
+ })
179
+ commandBusExecuteMock.mockResolvedValue({
180
+ result: {
181
+ status: 'updated',
182
+ channelId,
183
+ aiAutoReplyEnabled: true,
184
+ aiAgentId: FOREIGN_AGENT.id,
185
+ updatedAt: '2026-08-02T00:00:00.000Z',
186
+ },
187
+ })
188
+
189
+ const response = await PUT(
190
+ putRequest({ aiAutoReplyEnabled: true, aiAgentId: FOREIGN_AGENT.id }),
191
+ context,
192
+ )
193
+
194
+ expect(response.status).toBe(200)
195
+ })
196
+
197
+ it('accepts a wildcard role grant, as the runtime would', async () => {
198
+ resolveDiscordAiPrincipalMock.mockResolvedValue({
199
+ tenantId,
200
+ organizationId,
201
+ userId,
202
+ features: ['channel_discord.ai_auto_reply.run', 'customers.*'],
203
+ isSuperAdmin: false,
204
+ source: 'channel_bot_user',
205
+ })
206
+
207
+ const response = await PUT(
208
+ putRequest({ aiAutoReplyEnabled: true, aiAgentId: FOREIGN_AGENT.id }),
209
+ context,
210
+ )
211
+
212
+ expect(response.status).toBe(200)
213
+ })
214
+
215
+ it('never resolves a principal for a disarm, which needs no agent at all', async () => {
216
+ commandBusExecuteMock.mockResolvedValue({
217
+ result: {
218
+ status: 'updated',
219
+ channelId,
220
+ aiAutoReplyEnabled: false,
221
+ aiAgentId: null,
222
+ updatedAt: '2026-08-02T00:00:00.000Z',
223
+ },
224
+ })
225
+
226
+ const response = await PUT(putRequest({ aiAutoReplyEnabled: false }), context)
227
+
228
+ expect(response.status).toBe(200)
229
+ expect(resolveDiscordAiPrincipalMock).not.toHaveBeenCalled()
230
+ })
231
+
232
+ it('masks an unauthorized channel before it discloses anything about agents', async () => {
233
+ const notFound = new Response(JSON.stringify({ error: 'Channel not found' }), { status: 404 })
234
+ loadDiscordChannelForRequestMock.mockResolvedValue({ response: notFound })
235
+
236
+ const response = await PUT(
237
+ putRequest({ aiAutoReplyEnabled: true, aiAgentId: FOREIGN_AGENT.id }),
238
+ context,
239
+ )
240
+
241
+ expect(response.status).toBe(404)
242
+ expect(listDiscordEligibleAgentsMock).not.toHaveBeenCalled()
243
+ expect(resolveDiscordAiPrincipalMock).not.toHaveBeenCalled()
244
+ })
245
+
246
+ it('still reports an absent AI peer rather than a feature problem', async () => {
247
+ listDiscordEligibleAgentsMock.mockResolvedValue({ available: false })
248
+
249
+ const response = await PUT(
250
+ putRequest({ aiAutoReplyEnabled: true, aiAgentId: PROVIDER_AGENT.id }),
251
+ context,
252
+ )
253
+
254
+ expect(response.status).toBe(400)
255
+ const body = await response.json()
256
+ expect(body.fieldErrors.aiAutoReplyEnabled).toBe('channel_discord.aiAutoReply.errors.aiUnavailable')
257
+ })
258
+
259
+ it('still reports a non-object-mode agent as ineligible on shape', async () => {
260
+ listDiscordEligibleAgentsMock.mockResolvedValue({ available: true, agents: [PROVIDER_AGENT] })
261
+
262
+ const response = await PUT(
263
+ putRequest({ aiAutoReplyEnabled: true, aiAgentId: 'some.chat.agent' }),
264
+ context,
265
+ )
266
+
267
+ expect(response.status).toBe(400)
268
+ const body = await response.json()
269
+ expect(body.fieldErrors.aiAgentId).toBe('channel_discord.aiAutoReply.errors.agentNotEligible')
270
+ })
271
+ })
272
+
273
+ describe('GET /channel_discord/channels/{id}/ai-auto-reply — picker data', () => {
274
+ it('marks each offered agent with whether the principal could invoke it', async () => {
275
+ const response = await GET(getRequest(), context)
276
+
277
+ expect(response.status).toBe(200)
278
+ const body = await response.json()
279
+ expect(body.agents).toEqual([
280
+ expect.objectContaining({ id: PROVIDER_AGENT.id, invocable: true, missingFeatures: [] }),
281
+ expect.objectContaining({
282
+ id: FOREIGN_AGENT.id,
283
+ invocable: false,
284
+ missingFeatures: ['customers.view', 'customers.manage'],
285
+ }),
286
+ ])
287
+ })
288
+
289
+ it('surfaces the failure marker an armed channel is carrying', async () => {
290
+ loadDiscordChannelForRequestMock.mockResolvedValue({
291
+ channel: {
292
+ ...channel,
293
+ channelState: {
294
+ aiAutoReplyEnabled: true,
295
+ aiAgentId: FOREIGN_AGENT.id,
296
+ aiAutoReplyLastError: 'agent customers.support: agent_features_denied',
297
+ aiAutoReplyLastErrorAt: '2026-08-03T10:00:00.000Z',
298
+ },
299
+ },
300
+ organizationIds: [organizationId],
301
+ rbacOrganizationId: organizationId,
302
+ })
303
+
304
+ const response = await GET(getRequest(), context)
305
+ const body = await response.json()
306
+
307
+ expect(body.aiAutoReplyEnabled).toBe(true)
308
+ expect(body.aiAutoReplyLastError).toBe('agent customers.support: agent_features_denied')
309
+ expect(body.aiAutoReplyLastErrorAt).toBe('2026-08-03T10:00:00.000Z')
310
+ })
311
+ })