@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,149 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * Armed contract for the AI auto-reply subscriber (issue #4778).
6
+ *
7
+ * This test replaces the dormancy contract that shipped with #4391. That one
8
+ * asserted the inverse — that NO product surface could arm the subscriber —
9
+ * which was the honest statement while the feature was de-scoped. Now that the
10
+ * feature is real, the property worth pinning is the opposite one, and it is
11
+ * narrower: the two arming keys have exactly ONE writer, that writer is the
12
+ * settings command, and the operator surface in front of it is gated on
13
+ * `channel_discord.configure`.
14
+ *
15
+ * Structural assertions are worth having here because the properties they cover
16
+ * are invisible to a behavioural test: a second writer that skipped the command
17
+ * (and therefore the optimistic-lock check and the audit entry) would keep every
18
+ * unit test green, and so would a `features: []` regression in the subscriber —
19
+ * the exact defect the re-review of #4391 found.
20
+ */
21
+ const ARMING_KEYS = ['aiAutoReplyEnabled', 'aiAgentId'] as const
22
+
23
+ /** The only file allowed to WRITE the arming keys. */
24
+ const WRITER = 'commands/update-ai-auto-reply.ts'
25
+
26
+ /** Files allowed to name the keys at all, and the role each plays. */
27
+ const KEY_NAMERS = [
28
+ WRITER,
29
+ 'lib/ai-reply.ts', // reads the toggle + agent id for the subscriber
30
+ 'lib/credentials.ts', // declares the channelState schema they live in
31
+ 'lib/channel-state-store.ts', // carries them forward when the gateway writes resume state
32
+ 'api/channels/[id]/ai-auto-reply/route.ts', // GET reads them for the settings form, PUT validates the payload before the command
33
+ 'api/ai-auto-reply/channels/route.ts', // reads them for the integration panel's single-call listing
34
+ 'backend/channel_discord/channels/[id]/ai-auto-reply/page.tsx', // the settings form itself
35
+ 'widgets/injection/ai-auto-reply/widget.client.tsx', // shows the current state per channel
36
+ ] as const
37
+
38
+ /**
39
+ * Prose describes the very patterns these assertions forbid — the subscriber's
40
+ * own header explains why it never passes `features: []`. Matching against code
41
+ * only keeps a doc comment from failing the build it is documenting.
42
+ */
43
+ function stripComments(source: string): string {
44
+ return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
45
+ }
46
+
47
+ function collectSourceFiles(root: string): string[] {
48
+ const found: string[] = []
49
+ const walk = (dir: string) => {
50
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
51
+ const absolute = path.join(dir, entry.name)
52
+ if (entry.isDirectory()) {
53
+ if (entry.name === '__tests__' || entry.name === '__integration__') continue
54
+ walk(absolute)
55
+ continue
56
+ }
57
+ if (/\.(ts|tsx)$/.test(entry.name)) found.push(absolute)
58
+ }
59
+ }
60
+ walk(root)
61
+ return found
62
+ }
63
+
64
+ describe('channel_discord ai-auto-reply — armed contract', () => {
65
+ const packageSrc = path.resolve(__dirname, '../../../..')
66
+ const moduleRoot = path.join(packageSrc, 'modules', 'channel_discord')
67
+ const files = collectSourceFiles(packageSrc)
68
+ const relative = (file: string) => path.relative(moduleRoot, file).split(path.sep).join('/')
69
+ const readSettingsRoute = () =>
70
+ fs.readFileSync(path.join(moduleRoot, 'api', 'channels', '[id]', 'ai-auto-reply', 'route.ts'), 'utf8')
71
+
72
+ it('scans a real, non-empty source tree', () => {
73
+ expect(files.length).toBeGreaterThan(20)
74
+ expect(files).toContain(path.join(moduleRoot, 'subscribers', 'ai-auto-reply.ts'))
75
+ })
76
+
77
+ it('names the arming keys only in the files that read, validate, render or write them', () => {
78
+ const naming = files
79
+ .filter((file) => {
80
+ const source = fs.readFileSync(file, 'utf8')
81
+ return ARMING_KEYS.some((key) => source.includes(key))
82
+ })
83
+ .map(relative)
84
+ .sort()
85
+
86
+ expect(naming).toEqual([...KEY_NAMERS].sort())
87
+ })
88
+
89
+ it('writes channelState from exactly two places — the gateway store and the settings command', () => {
90
+ const assigning = files
91
+ // `.channelState =` is the entity write; a bare `channelState =` is a local
92
+ // binding (the gateway worker parses one to read resume state).
93
+ .filter((file) => /\.channelState\s*=[^=]/.test(stripComments(fs.readFileSync(file, 'utf8'))))
94
+ .map(relative)
95
+ .sort()
96
+
97
+ // Any third writer is a deliberate addition that has to argue for itself:
98
+ // the gateway store carries the AI keys forward, and the settings command is
99
+ // the only thing allowed to change them.
100
+ expect(assigning).toEqual([WRITER, 'lib/channel-state-store.ts'].sort())
101
+ })
102
+
103
+ it('persists the settings through the command bus, not a direct entity write', () => {
104
+ const route = stripComments(readSettingsRoute())
105
+ expect(route).toContain('CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID')
106
+ expect(route).not.toMatch(/\.flush\(/)
107
+ expect(route).not.toMatch(/nativeUpdate/)
108
+ })
109
+
110
+ it('never invokes the agent with an empty feature set', () => {
111
+ const subscriber = stripComments(
112
+ fs.readFileSync(path.join(moduleRoot, 'subscribers', 'ai-auto-reply.ts'), 'utf8'),
113
+ )
114
+ expect(subscriber).not.toMatch(/features:\s*\[\s*\]/)
115
+ expect(subscriber).toContain('principal.features')
116
+ expect(subscriber).toContain('isSuperAdmin: principal.isSuperAdmin')
117
+ })
118
+
119
+ it('refuses to arm a channel against an agent the auto-reply principal cannot invoke', () => {
120
+ // Structural, because the behavioural half lives in the route test and this
121
+ // is the property that must not quietly disappear: object-mode eligibility is
122
+ // a SHAPE check, and on its own it re-opens the dormancy hole — the runtime
123
+ // also enforces `requiredFeatures` against the principal from
124
+ // `lib/ai-service-principal.ts`, so the save path has to ask the same
125
+ // question with the same principal.
126
+ const route = stripComments(readSettingsRoute())
127
+ expect(route).toContain('missingAgentFeatures')
128
+ expect(route).toContain('resolveDiscordAiPrincipal')
129
+ })
130
+
131
+ it('records why an armed channel produced nothing, instead of only logging it', () => {
132
+ const subscriber = stripComments(
133
+ fs.readFileSync(path.join(moduleRoot, 'subscribers', 'ai-auto-reply.ts'), 'utf8'),
134
+ )
135
+ // The no-op on failure is correct; being silent about it is what made the
136
+ // feature dormant. The marker write must survive any refactor of the catch.
137
+ expect(subscriber).toContain('recordDiscordAutoReplyOutcome')
138
+ })
139
+
140
+ it('gates the write route on the configure feature, not merely on being signed in', () => {
141
+ // The settings route exposes GET and PUT from one file, so assert on the PUT
142
+ // metadata block specifically: a file-wide `toContain` would still pass if the
143
+ // configure feature only ever guarded the read half.
144
+ const putMetadata = readSettingsRoute().match(/\n {2}PUT: \{[\s\S]*?\n {2}\},/)?.[0]
145
+ expect(putMetadata).toBeDefined()
146
+ expect(putMetadata).toContain('requireAuth: true')
147
+ expect(putMetadata).toContain('CHANNEL_DISCORD_CONFIGURE_FEATURE')
148
+ })
149
+ })
@@ -0,0 +1,133 @@
1
+ /**
2
+ * What an armed Discord channel does when the agent call keeps being refused.
3
+ *
4
+ * The subscriber degrades to a no-op, and that part is right — a broken model
5
+ * must never become a send. The part that was wrong is that the no-op was
6
+ * *silent*: the settings surface still read "Auto-reply on", the channel answered
7
+ * nothing, and the only trace was a `logger.warn` in a background subscriber. A
8
+ * save-time feature check narrows the window but cannot close it, because a role
9
+ * edited after the channel was armed makes that verdict stale.
10
+ *
11
+ * So the contract here is: every failure leaves a reason on the channel, the next
12
+ * success clears it, and neither the marker write nor its failure can turn a
13
+ * degraded no-op into a thrown handler.
14
+ */
15
+ const recordDiscordAutoReplyOutcomeMock = jest.fn(async () => 'written' as const)
16
+
17
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
18
+ findOneWithDecryption: jest.fn(),
19
+ }))
20
+ jest.mock('@open-mercato/core/modules/communication_channels/lib/system-user', () => ({
21
+ COMMUNICATION_CHANNELS_SYSTEM_USER_ID: '00000000-0000-0000-0000-000000000000',
22
+ resolveCommunicationChannelsSystemUserId: jest.fn(async () => 'system-user-id'),
23
+ }))
24
+ jest.mock('@open-mercato/ai-assistant', () => ({ runAiAgentObject: jest.fn() }), { virtual: true })
25
+ jest.mock('../../lib/channel-state-store', () => ({
26
+ recordDiscordAutoReplyOutcome: (...args: unknown[]) => recordDiscordAutoReplyOutcomeMock(...(args as [])),
27
+ }))
28
+
29
+ import handler from '../ai-auto-reply'
30
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
31
+ import { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from '../../ai-agents'
32
+
33
+ const findOne = findOneWithDecryption as unknown as jest.Mock
34
+ const aiMod = jest.requireMock('@open-mercato/ai-assistant') as { runAiAgentObject: jest.Mock }
35
+
36
+ const payload = {
37
+ providerKey: 'discord' as const,
38
+ messageId: 'm-1',
39
+ channelId: 'c-1',
40
+ tenantId: 't-1',
41
+ organizationId: 'o-1',
42
+ direction: 'inbound' as const,
43
+ }
44
+
45
+ function makeCtx() {
46
+ const forked = { findOne: jest.fn(async () => null) }
47
+ const em = { fork: () => forked }
48
+ const commandBus = { execute: jest.fn(async () => ({ result: { id: 'reply-msg', threadId: null } })) }
49
+ const resolve = jest.fn((name: string) => {
50
+ if (name === 'em') return em
51
+ if (name === 'mcpToolRegistry') return {}
52
+ if (name === 'commandBus') return commandBus
53
+ return {}
54
+ })
55
+ return { ctx: { resolve }, commandBus }
56
+ }
57
+
58
+ function channelRow(channelState: Record<string, unknown>) {
59
+ return { id: 'c-1', tenantId: 't-1', organizationId: 'o-1', userId: null, channelState }
60
+ }
61
+
62
+ const armed = { aiAutoReplyEnabled: true, aiAgentId: CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID }
63
+
64
+ function messageRow() {
65
+ return { id: 'm-1', threadId: 'thread-1', subject: 'Discord', type: 'channel.discord', body: 'Are you open?' }
66
+ }
67
+
68
+ beforeEach(() => {
69
+ jest.clearAllMocks()
70
+ recordDiscordAutoReplyOutcomeMock.mockResolvedValue('written')
71
+ })
72
+
73
+ describe('channel_discord ai-auto-reply — failure visibility', () => {
74
+ it('parks the reason on the channel when the runtime refuses the agent', async () => {
75
+ findOne.mockResolvedValueOnce(channelRow(armed)).mockResolvedValueOnce(messageRow())
76
+ aiMod.runAiAgentObject.mockRejectedValue(new Error('agent_features_denied'))
77
+ const { ctx, commandBus } = makeCtx()
78
+
79
+ await expect(handler(payload, ctx)).resolves.toBeUndefined()
80
+
81
+ // Still a no-op — nothing was sent and no proposal was filed.
82
+ expect(commandBus.execute).not.toHaveBeenCalled()
83
+ expect(recordDiscordAutoReplyOutcomeMock).toHaveBeenCalledTimes(1)
84
+ const call = recordDiscordAutoReplyOutcomeMock.mock.calls[0][0] as {
85
+ channelId: string
86
+ scope: { tenantId: string; organizationId: string | null }
87
+ failure: string | null
88
+ }
89
+ expect(call.channelId).toBe('c-1')
90
+ expect(call.scope).toEqual({ tenantId: 't-1', organizationId: 'o-1' })
91
+ // The reason names the agent, because "which agent" is the first thing an
92
+ // operator staring at a silent channel needs to know.
93
+ expect(call.failure).toBe(`agent ${CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID}: agent_features_denied`)
94
+ })
95
+
96
+ it('clears a stale marker on the next attempt that gets through', async () => {
97
+ findOne
98
+ .mockResolvedValueOnce(channelRow({ ...armed, aiAutoReplyLastError: 'agent x: denied' }))
99
+ .mockResolvedValueOnce(messageRow())
100
+ aiMod.runAiAgentObject.mockResolvedValue({
101
+ mode: 'generate',
102
+ object: { reply: 'We are open 9-5.', summary: 'Hours.', confidence: 0.9, requiresHuman: false },
103
+ })
104
+ const { ctx } = makeCtx()
105
+
106
+ await handler(payload, ctx)
107
+
108
+ expect(recordDiscordAutoReplyOutcomeMock).toHaveBeenCalledTimes(1)
109
+ expect((recordDiscordAutoReplyOutcomeMock.mock.calls[0][0] as { failure: string | null }).failure).toBeNull()
110
+ })
111
+
112
+ it('does not pay a write on a healthy channel that has nothing to clear', async () => {
113
+ findOne.mockResolvedValueOnce(channelRow(armed)).mockResolvedValueOnce(messageRow())
114
+ aiMod.runAiAgentObject.mockResolvedValue({
115
+ mode: 'generate',
116
+ object: { reply: 'We are open 9-5.', summary: 'Hours.', confidence: 0.9, requiresHuman: false },
117
+ })
118
+ const { ctx } = makeCtx()
119
+
120
+ await handler(payload, ctx)
121
+
122
+ expect(recordDiscordAutoReplyOutcomeMock).not.toHaveBeenCalled()
123
+ })
124
+
125
+ it('never lets the marker write itself escalate a no-op into a thrown handler', async () => {
126
+ findOne.mockResolvedValueOnce(channelRow(armed)).mockResolvedValueOnce(messageRow())
127
+ aiMod.runAiAgentObject.mockRejectedValue(new Error('boom'))
128
+ recordDiscordAutoReplyOutcomeMock.mockRejectedValue(new Error('database is down'))
129
+ const { ctx } = makeCtx()
130
+
131
+ await expect(handler(payload, ctx)).resolves.toBeUndefined()
132
+ })
133
+ })
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Unit coverage for the AI auto-reply subscriber's control flow (issue #4778).
3
+ *
4
+ * The AI runtime is stubbed here on purpose: `@open-mercato/ai-assistant` is a
5
+ * soft-optional peer, so a unit test that imported the real runtime would couple
6
+ * this package to it and break the decoupling property the subscriber exists to
7
+ * respect. What the stub cannot prove — that the REAL agent policy accepts the
8
+ * call the subscriber makes — is proven separately, against the real
9
+ * `agent-policy` / `agent-runtime`, in
10
+ * `packages/channel-discord/src/modules/channel_discord/__tests__/ai-auto-reply.policy.integration.test.ts`.
11
+ *
12
+ * The three tiers this file pins down:
13
+ * - easy + the model is confident and asks for no human → auto-send;
14
+ * - anything else → a proposal a human approves, never a send;
15
+ * - peer absent / channel not armed / malformed output → clean no-op.
16
+ */
17
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
18
+ findOneWithDecryption: jest.fn(),
19
+ }))
20
+ jest.mock('@open-mercato/core/modules/communication_channels/lib/system-user', () => ({
21
+ COMMUNICATION_CHANNELS_SYSTEM_USER_ID: '00000000-0000-0000-0000-000000000000',
22
+ resolveCommunicationChannelsSystemUserId: jest.fn(async () => 'system-user-id'),
23
+ }))
24
+ jest.mock('@open-mercato/ai-assistant', () => ({ runAiAgentObject: jest.fn() }), { virtual: true })
25
+
26
+ import handler from '../ai-auto-reply'
27
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
28
+ import { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from '../../ai-agents'
29
+ import { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from '../../message-types'
30
+
31
+ const findOne = findOneWithDecryption as unknown as jest.Mock
32
+ const aiMod = jest.requireMock('@open-mercato/ai-assistant') as { runAiAgentObject: jest.Mock }
33
+
34
+ type ResolveMap = {
35
+ commandBus?: { execute: jest.Mock }
36
+ aiPresent?: boolean
37
+ threadAssignee?: string | null
38
+ }
39
+
40
+ function makeCtx(map: ResolveMap) {
41
+ const forked = {
42
+ findOne: jest.fn(async () =>
43
+ map.threadAssignee ? { assignedUserId: map.threadAssignee } : null,
44
+ ),
45
+ }
46
+ const em = { fork: () => forked }
47
+ const commandBus =
48
+ map.commandBus ?? { execute: jest.fn(async () => ({ result: { id: 'reply-msg', threadId: null } })) }
49
+ const resolve = jest.fn((name: string) => {
50
+ if (name === 'em') return em
51
+ if (name === 'mcpToolRegistry') {
52
+ if (map.aiPresent === false) throw new Error('ai_assistant not registered')
53
+ return {}
54
+ }
55
+ if (name === 'commandBus') return commandBus
56
+ return {}
57
+ })
58
+ return { ctx: { resolve }, commandBus, resolve, forked }
59
+ }
60
+
61
+ const basePayload = {
62
+ providerKey: 'discord' as const,
63
+ messageId: 'm-1',
64
+ channelId: 'c-1',
65
+ tenantId: 't-1',
66
+ organizationId: 'o-1',
67
+ direction: 'inbound' as const,
68
+ }
69
+
70
+ function channelRow(overrides: Record<string, unknown> = {}) {
71
+ return {
72
+ id: 'c-1',
73
+ tenantId: 't-1',
74
+ organizationId: 'o-1',
75
+ userId: null,
76
+ channelState: { aiAutoReplyEnabled: true, aiAgentId: CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID },
77
+ ...overrides,
78
+ }
79
+ }
80
+
81
+ function messageRow(body: string) {
82
+ return { id: 'm-1', threadId: 'thread-1', subject: 'Discord', type: 'channel.discord', body }
83
+ }
84
+
85
+ function agentOutput(overrides: Record<string, unknown> = {}) {
86
+ return {
87
+ mode: 'generate',
88
+ object: {
89
+ reply: 'We are open 9-5.',
90
+ summary: 'Asks about opening hours.',
91
+ confidence: 0.9,
92
+ requiresHuman: false,
93
+ ...overrides,
94
+ },
95
+ }
96
+ }
97
+
98
+ describe('channel_discord ai-auto-reply subscriber — cheap early returns', () => {
99
+ it('no-ops for a non-discord provider without touching the container', async () => {
100
+ const resolve = jest.fn(() => {
101
+ throw new Error('resolver should not be called')
102
+ })
103
+ await expect(
104
+ handler({ providerKey: 'gmail', messageId: 'm', channelId: 'c', tenantId: 't', direction: 'inbound' }, { resolve }),
105
+ ).resolves.toBeUndefined()
106
+ expect(resolve).not.toHaveBeenCalled()
107
+ })
108
+
109
+ it('no-ops for an outbound message', async () => {
110
+ const resolve = jest.fn(() => {
111
+ throw new Error('resolver should not be called')
112
+ })
113
+ await expect(
114
+ handler(
115
+ { providerKey: 'discord', messageId: 'm', channelId: 'c', tenantId: 't', direction: 'outbound' },
116
+ { resolve },
117
+ ),
118
+ ).resolves.toBeUndefined()
119
+ expect(resolve).not.toHaveBeenCalled()
120
+ })
121
+
122
+ it('no-ops when required payload fields are missing', async () => {
123
+ const resolve = jest.fn(() => {
124
+ throw new Error('resolver should not be called')
125
+ })
126
+ await expect(handler({ providerKey: 'discord', direction: 'inbound' }, { resolve })).resolves.toBeUndefined()
127
+ expect(resolve).not.toHaveBeenCalled()
128
+ })
129
+ })
130
+
131
+ describe('channel_discord ai-auto-reply subscriber — routing', () => {
132
+ beforeEach(() => {
133
+ findOne.mockReset()
134
+ aiMod.runAiAgentObject.mockReset()
135
+ aiMod.runAiAgentObject.mockResolvedValue(agentOutput())
136
+ })
137
+
138
+ it('(easy, confident) sends through the generic hub compose command', async () => {
139
+ findOne.mockResolvedValueOnce(channelRow()).mockResolvedValueOnce(messageRow('What are your opening hours?'))
140
+ const { ctx, commandBus } = makeCtx({ aiPresent: true })
141
+
142
+ await handler(basePayload, ctx)
143
+
144
+ expect(aiMod.runAiAgentObject).toHaveBeenCalledTimes(1)
145
+ expect(commandBus.execute).toHaveBeenCalledTimes(1)
146
+ const [commandId, args] = commandBus.execute.mock.calls[0]
147
+ expect(commandId).toBe('messages.messages.compose')
148
+ expect(args.input.body).toContain('We are open 9-5.')
149
+ expect(args.input.parentMessageId).toBe('thread-1')
150
+ expect(args.input.visibility).toBe('public')
151
+ expect(args.input.isDraft).toBe(false)
152
+ })
153
+
154
+ it('runs the agent under a real service principal, never features: [] and never super-admin', async () => {
155
+ findOne.mockResolvedValueOnce(channelRow()).mockResolvedValueOnce(messageRow('What are your opening hours?'))
156
+ const { ctx } = makeCtx({ aiPresent: true })
157
+
158
+ await handler(basePayload, ctx)
159
+
160
+ const [call] = aiMod.runAiAgentObject.mock.calls
161
+ expect(call[0].agentId).toBe(CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID)
162
+ expect(call[0].authContext.features).toContain('channel_discord.ai_auto_reply.run')
163
+ expect(call[0].authContext.features.length).toBeGreaterThan(0)
164
+ expect(call[0].authContext.isSuperAdmin).toBe(false)
165
+ expect(call[0].authContext.tenantId).toBe('t-1')
166
+ expect(call[0].authContext.organizationId).toBe('o-1')
167
+ })
168
+
169
+ it('(complex) drafts a proposal for a human and NEVER auto-sends', async () => {
170
+ findOne
171
+ .mockResolvedValueOnce(channelRow())
172
+ .mockResolvedValueOnce(messageRow('I want a refund on my order'))
173
+ .mockResolvedValueOnce(channelRow())
174
+ const { ctx, commandBus } = makeCtx({ aiPresent: true, threadAssignee: 'agent-user-1' })
175
+
176
+ await handler(basePayload, ctx)
177
+
178
+ // The agent still runs — the proposal has to contain a drafted reply.
179
+ expect(aiMod.runAiAgentObject).toHaveBeenCalledTimes(1)
180
+ expect(commandBus.execute).toHaveBeenCalledTimes(1)
181
+ const [, args] = commandBus.execute.mock.calls[0]
182
+ expect(args.input.type).toBe(CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE)
183
+ expect(args.input.visibility).toBe('internal')
184
+ expect(args.input.recipients).toEqual([{ userId: 'agent-user-1', type: 'to' }])
185
+ expect(args.input.sourceEntityId).toBe('m-1')
186
+ expect(args.input.body).toContain('We are open 9-5.')
187
+ })
188
+
189
+ it('(complex, nobody assigned) stores the proposal as a draft rather than losing it', async () => {
190
+ findOne
191
+ .mockResolvedValueOnce(channelRow())
192
+ .mockResolvedValueOnce(messageRow('I want a refund on my order'))
193
+ .mockResolvedValueOnce(channelRow())
194
+ const { ctx, commandBus } = makeCtx({ aiPresent: true, threadAssignee: null })
195
+
196
+ await handler(basePayload, ctx)
197
+
198
+ const [, args] = commandBus.execute.mock.calls[0]
199
+ expect(args.input.isDraft).toBe(true)
200
+ expect(args.input.recipients).toEqual([])
201
+ expect(args.input.visibility).toBe('internal')
202
+ })
203
+
204
+ it('(easy, model asks for a human) proposes instead of sending', async () => {
205
+ aiMod.runAiAgentObject.mockResolvedValue(agentOutput({ requiresHuman: true }))
206
+ findOne
207
+ .mockResolvedValueOnce(channelRow())
208
+ .mockResolvedValueOnce(messageRow('What are your opening hours?'))
209
+ .mockResolvedValueOnce(channelRow())
210
+ const { ctx, commandBus } = makeCtx({ aiPresent: true, threadAssignee: 'agent-user-1' })
211
+
212
+ await handler(basePayload, ctx)
213
+
214
+ const [, args] = commandBus.execute.mock.calls[0]
215
+ expect(args.input.type).toBe(CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE)
216
+ })
217
+
218
+ it('(easy, low confidence) proposes instead of sending', async () => {
219
+ aiMod.runAiAgentObject.mockResolvedValue(agentOutput({ confidence: 0.4 }))
220
+ findOne
221
+ .mockResolvedValueOnce(channelRow())
222
+ .mockResolvedValueOnce(messageRow('What are your opening hours?'))
223
+ .mockResolvedValueOnce(channelRow())
224
+ const { ctx, commandBus } = makeCtx({ aiPresent: true, threadAssignee: 'agent-user-1' })
225
+
226
+ await handler(basePayload, ctx)
227
+
228
+ const [, args] = commandBus.execute.mock.calls[0]
229
+ expect(args.input.type).toBe(CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE)
230
+ })
231
+
232
+ it('(malformed agent output) degrades to a no-op instead of sending something unvalidated', async () => {
233
+ aiMod.runAiAgentObject.mockResolvedValue({ mode: 'generate', object: { reply: 'hi' } })
234
+ findOne.mockResolvedValueOnce(channelRow()).mockResolvedValueOnce(messageRow('What are your opening hours?'))
235
+ const { ctx, commandBus } = makeCtx({ aiPresent: true })
236
+
237
+ await expect(handler(basePayload, ctx)).resolves.toBeUndefined()
238
+ expect(commandBus.execute).not.toHaveBeenCalled()
239
+ })
240
+
241
+ it('(no ai_assistant) is a clean no-op — no message load, no send', async () => {
242
+ findOne.mockResolvedValueOnce(channelRow())
243
+ const { ctx, commandBus } = makeCtx({ aiPresent: false })
244
+
245
+ await expect(handler(basePayload, ctx)).resolves.toBeUndefined()
246
+
247
+ expect(findOne).toHaveBeenCalledTimes(1) // channel only; message never fetched
248
+ expect(aiMod.runAiAgentObject).not.toHaveBeenCalled()
249
+ expect(commandBus.execute).not.toHaveBeenCalled()
250
+ })
251
+
252
+ it('(disabled) no-ops when per-channel auto-reply is OFF (default)', async () => {
253
+ findOne.mockResolvedValueOnce(channelRow({ channelState: {} }))
254
+ const { ctx, commandBus } = makeCtx({ aiPresent: true })
255
+
256
+ await handler(basePayload, ctx)
257
+
258
+ expect(findOne).toHaveBeenCalledTimes(1)
259
+ expect(commandBus.execute).not.toHaveBeenCalled()
260
+ })
261
+
262
+ it('loads the channel scoped by tenant + organization', async () => {
263
+ findOne.mockResolvedValueOnce(channelRow()).mockResolvedValueOnce(messageRow('hi there'))
264
+ const { ctx } = makeCtx({ aiPresent: true })
265
+
266
+ await handler(basePayload, ctx)
267
+
268
+ const [, entityArg, where, , dscope] = findOne.mock.calls[0]
269
+ expect(entityArg).toBeDefined()
270
+ expect(where).toMatchObject({ id: 'c-1', tenantId: 't-1', organizationId: 'o-1', deletedAt: null })
271
+ expect(dscope).toEqual({ tenantId: 't-1', organizationId: 'o-1' })
272
+ })
273
+ })