@open-mercato/channel-discord 0.6.8-develop.7090.1.4dbca7b350

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/build.mjs +7 -0
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +7 -0
  5. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js +86 -0
  6. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js.map +7 -0
  7. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js +39 -0
  8. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js.map +7 -0
  9. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js +97 -0
  10. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js.map +7 -0
  11. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js +65 -0
  12. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js.map +7 -0
  13. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js +110 -0
  14. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js.map +7 -0
  15. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js +103 -0
  16. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js.map +7 -0
  17. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js +93 -0
  18. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js.map +7 -0
  19. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js +52 -0
  20. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js.map +7 -0
  21. package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js +46 -0
  22. package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js.map +7 -0
  23. package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js +16 -0
  24. package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js.map +7 -0
  25. package/dist/modules/channel_discord/__integration__/meta.js +10 -0
  26. package/dist/modules/channel_discord/__integration__/meta.js.map +7 -0
  27. package/dist/modules/channel_discord/acl.js +15 -0
  28. package/dist/modules/channel_discord/acl.js.map +7 -0
  29. package/dist/modules/channel_discord/ai-agents.js +97 -0
  30. package/dist/modules/channel_discord/ai-agents.js.map +7 -0
  31. package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js +73 -0
  32. package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js.map +7 -0
  33. package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js +222 -0
  34. package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js.map +7 -0
  35. package/dist/modules/channel_discord/api/interactions/route.js +173 -0
  36. package/dist/modules/channel_discord/api/interactions/route.js.map +7 -0
  37. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js +196 -0
  38. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js.map +7 -0
  39. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js +14 -0
  40. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js.map +7 -0
  41. package/dist/modules/channel_discord/cli.js +142 -0
  42. package/dist/modules/channel_discord/cli.js.map +7 -0
  43. package/dist/modules/channel_discord/commands/ai-reply-proposal.js +116 -0
  44. package/dist/modules/channel_discord/commands/ai-reply-proposal.js.map +7 -0
  45. package/dist/modules/channel_discord/commands/update-ai-auto-reply.js +104 -0
  46. package/dist/modules/channel_discord/commands/update-ai-auto-reply.js.map +7 -0
  47. package/dist/modules/channel_discord/di.js +23 -0
  48. package/dist/modules/channel_discord/di.js.map +7 -0
  49. package/dist/modules/channel_discord/index.js +9 -0
  50. package/dist/modules/channel_discord/index.js.map +7 -0
  51. package/dist/modules/channel_discord/integration.js +96 -0
  52. package/dist/modules/channel_discord/integration.js.map +7 -0
  53. package/dist/modules/channel_discord/lib/adapter.js +248 -0
  54. package/dist/modules/channel_discord/lib/adapter.js.map +7 -0
  55. package/dist/modules/channel_discord/lib/ai-agent-directory.js +45 -0
  56. package/dist/modules/channel_discord/lib/ai-agent-directory.js.map +7 -0
  57. package/dist/modules/channel_discord/lib/ai-features.js +9 -0
  58. package/dist/modules/channel_discord/lib/ai-features.js.map +7 -0
  59. package/dist/modules/channel_discord/lib/ai-proposal-contract.js +9 -0
  60. package/dist/modules/channel_discord/lib/ai-proposal-contract.js.map +7 -0
  61. package/dist/modules/channel_discord/lib/ai-proposal.js +109 -0
  62. package/dist/modules/channel_discord/lib/ai-proposal.js.map +7 -0
  63. package/dist/modules/channel_discord/lib/ai-reply.js +96 -0
  64. package/dist/modules/channel_discord/lib/ai-reply.js.map +7 -0
  65. package/dist/modules/channel_discord/lib/ai-service-principal.js +70 -0
  66. package/dist/modules/channel_discord/lib/ai-service-principal.js.map +7 -0
  67. package/dist/modules/channel_discord/lib/capabilities.js +42 -0
  68. package/dist/modules/channel_discord/lib/capabilities.js.map +7 -0
  69. package/dist/modules/channel_discord/lib/channel-access.js +62 -0
  70. package/dist/modules/channel_discord/lib/channel-access.js.map +7 -0
  71. package/dist/modules/channel_discord/lib/channel-state-store.js +103 -0
  72. package/dist/modules/channel_discord/lib/channel-state-store.js.map +7 -0
  73. package/dist/modules/channel_discord/lib/convert-outbound.js +38 -0
  74. package/dist/modules/channel_discord/lib/convert-outbound.js.map +7 -0
  75. package/dist/modules/channel_discord/lib/credentials.js +61 -0
  76. package/dist/modules/channel_discord/lib/credentials.js.map +7 -0
  77. package/dist/modules/channel_discord/lib/discord-gateway-client.js +289 -0
  78. package/dist/modules/channel_discord/lib/discord-gateway-client.js.map +7 -0
  79. package/dist/modules/channel_discord/lib/discord-rest.js +203 -0
  80. package/dist/modules/channel_discord/lib/discord-rest.js.map +7 -0
  81. package/dist/modules/channel_discord/lib/failure-reason.js +30 -0
  82. package/dist/modules/channel_discord/lib/failure-reason.js.map +7 -0
  83. package/dist/modules/channel_discord/lib/gateway-bridge.js +42 -0
  84. package/dist/modules/channel_discord/lib/gateway-bridge.js.map +7 -0
  85. package/dist/modules/channel_discord/lib/health.js +37 -0
  86. package/dist/modules/channel_discord/lib/health.js.map +7 -0
  87. package/dist/modules/channel_discord/lib/interactions-dispatch.js +164 -0
  88. package/dist/modules/channel_discord/lib/interactions-dispatch.js.map +7 -0
  89. package/dist/modules/channel_discord/lib/interactions-handler.js +115 -0
  90. package/dist/modules/channel_discord/lib/interactions-handler.js.map +7 -0
  91. package/dist/modules/channel_discord/lib/interactions-queue.js +17 -0
  92. package/dist/modules/channel_discord/lib/interactions-queue.js.map +7 -0
  93. package/dist/modules/channel_discord/lib/interactions-verify.js +84 -0
  94. package/dist/modules/channel_discord/lib/interactions-verify.js.map +7 -0
  95. package/dist/modules/channel_discord/lib/normalize-inbound.js +59 -0
  96. package/dist/modules/channel_discord/lib/normalize-inbound.js.map +7 -0
  97. package/dist/modules/channel_discord/lib/preset.js +63 -0
  98. package/dist/modules/channel_discord/lib/preset.js.map +7 -0
  99. package/dist/modules/channel_discord/lib/queue-strategy.js +12 -0
  100. package/dist/modules/channel_discord/lib/queue-strategy.js.map +7 -0
  101. package/dist/modules/channel_discord/lib/slash-commands.js +42 -0
  102. package/dist/modules/channel_discord/lib/slash-commands.js.map +7 -0
  103. package/dist/modules/channel_discord/message-types.js +64 -0
  104. package/dist/modules/channel_discord/message-types.js.map +7 -0
  105. package/dist/modules/channel_discord/setup.js +62 -0
  106. package/dist/modules/channel_discord/setup.js.map +7 -0
  107. package/dist/modules/channel_discord/subscribers/ai-auto-reply.js +185 -0
  108. package/dist/modules/channel_discord/subscribers/ai-auto-reply.js.map +7 -0
  109. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js +79 -0
  110. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js.map +7 -0
  111. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js +20 -0
  112. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js.map +7 -0
  113. package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js +220 -0
  114. package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js.map +7 -0
  115. package/dist/modules/channel_discord/widgets/injection/connect/widget.js +17 -0
  116. package/dist/modules/channel_discord/widgets/injection/connect/widget.js.map +7 -0
  117. package/dist/modules/channel_discord/widgets/injection-table.js +26 -0
  118. package/dist/modules/channel_discord/widgets/injection-table.js.map +7 -0
  119. package/dist/modules/channel_discord/workers/discord-gateway.js +270 -0
  120. package/dist/modules/channel_discord/workers/discord-gateway.js.map +7 -0
  121. package/dist/modules/channel_discord/workers/discord-interactions.js +113 -0
  122. package/dist/modules/channel_discord/workers/discord-interactions.js.map +7 -0
  123. package/jest.config.cjs +38 -0
  124. package/package.json +104 -0
  125. package/src/index.ts +1 -0
  126. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.ts +109 -0
  127. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.ts +58 -0
  128. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.ts +175 -0
  129. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.ts +83 -0
  130. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.ts +156 -0
  131. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.ts +144 -0
  132. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.ts +119 -0
  133. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.ts +75 -0
  134. package/src/modules/channel_discord/__integration__/helpers/discordSignature.ts +77 -0
  135. package/src/modules/channel_discord/__integration__/helpers/eventsQueue.ts +26 -0
  136. package/src/modules/channel_discord/__integration__/meta.ts +6 -0
  137. package/src/modules/channel_discord/__tests__/ai-auto-reply.policy.integration.test.ts +286 -0
  138. package/src/modules/channel_discord/acl.ts +19 -0
  139. package/src/modules/channel_discord/ai-agents.ts +157 -0
  140. package/src/modules/channel_discord/api/ai-auto-reply/channels/__tests__/route.test.ts +175 -0
  141. package/src/modules/channel_discord/api/ai-auto-reply/channels/route.ts +99 -0
  142. package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/__tests__/route.test.ts +311 -0
  143. package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.ts +299 -0
  144. package/src/modules/channel_discord/api/interactions/route.ts +242 -0
  145. package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.ts +18 -0
  146. package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.tsx +288 -0
  147. package/src/modules/channel_discord/cli.ts +234 -0
  148. package/src/modules/channel_discord/commands/__tests__/ai-reply-proposal.test.ts +155 -0
  149. package/src/modules/channel_discord/commands/__tests__/update-ai-auto-reply.test.ts +185 -0
  150. package/src/modules/channel_discord/commands/ai-reply-proposal.ts +162 -0
  151. package/src/modules/channel_discord/commands/update-ai-auto-reply.ts +157 -0
  152. package/src/modules/channel_discord/di.ts +26 -0
  153. package/src/modules/channel_discord/i18n/de.json +57 -0
  154. package/src/modules/channel_discord/i18n/en.json +57 -0
  155. package/src/modules/channel_discord/i18n/es.json +57 -0
  156. package/src/modules/channel_discord/i18n/ko.json +57 -0
  157. package/src/modules/channel_discord/i18n/pl.json +57 -0
  158. package/src/modules/channel_discord/index.ts +6 -0
  159. package/src/modules/channel_discord/integration.ts +98 -0
  160. package/src/modules/channel_discord/lib/__tests__/adapter.test.ts +206 -0
  161. package/src/modules/channel_discord/lib/__tests__/ai-agent-directory.test.ts +61 -0
  162. package/src/modules/channel_discord/lib/__tests__/ai-reply.test.ts +115 -0
  163. package/src/modules/channel_discord/lib/__tests__/capabilities.test.ts +170 -0
  164. package/src/modules/channel_discord/lib/__tests__/channel-quarantine.test.ts +89 -0
  165. package/src/modules/channel_discord/lib/__tests__/channel-state-store.auto-reply-marker.test.ts +145 -0
  166. package/src/modules/channel_discord/lib/__tests__/channel-state-store.test.ts +78 -0
  167. package/src/modules/channel_discord/lib/__tests__/convert-outbound.test.ts +39 -0
  168. package/src/modules/channel_discord/lib/__tests__/discord-gateway-client.test.ts +243 -0
  169. package/src/modules/channel_discord/lib/__tests__/failure-reason.test.ts +68 -0
  170. package/src/modules/channel_discord/lib/__tests__/gateway-bridge.test.ts +62 -0
  171. package/src/modules/channel_discord/lib/__tests__/interactions-dispatch.test.ts +223 -0
  172. package/src/modules/channel_discord/lib/__tests__/interactions-handler.test.ts +469 -0
  173. package/src/modules/channel_discord/lib/__tests__/interactions-verify.test.ts +126 -0
  174. package/src/modules/channel_discord/lib/__tests__/normalize-inbound.identity.test.ts +64 -0
  175. package/src/modules/channel_discord/lib/__tests__/normalize-inbound.test.ts +67 -0
  176. package/src/modules/channel_discord/lib/__tests__/preset.test.ts +110 -0
  177. package/src/modules/channel_discord/lib/__tests__/queue-strategy.test.ts +33 -0
  178. package/src/modules/channel_discord/lib/adapter.ts +359 -0
  179. package/src/modules/channel_discord/lib/ai-agent-directory.ts +124 -0
  180. package/src/modules/channel_discord/lib/ai-features.ts +23 -0
  181. package/src/modules/channel_discord/lib/ai-proposal-contract.ts +24 -0
  182. package/src/modules/channel_discord/lib/ai-proposal.ts +179 -0
  183. package/src/modules/channel_discord/lib/ai-reply.ts +179 -0
  184. package/src/modules/channel_discord/lib/ai-service-principal.ts +143 -0
  185. package/src/modules/channel_discord/lib/capabilities.ts +104 -0
  186. package/src/modules/channel_discord/lib/channel-access.ts +110 -0
  187. package/src/modules/channel_discord/lib/channel-state-store.ts +217 -0
  188. package/src/modules/channel_discord/lib/convert-outbound.ts +88 -0
  189. package/src/modules/channel_discord/lib/credentials.ts +88 -0
  190. package/src/modules/channel_discord/lib/discord-gateway-client.ts +432 -0
  191. package/src/modules/channel_discord/lib/discord-rest.ts +393 -0
  192. package/src/modules/channel_discord/lib/failure-reason.ts +49 -0
  193. package/src/modules/channel_discord/lib/gateway-bridge.ts +79 -0
  194. package/src/modules/channel_discord/lib/health.ts +55 -0
  195. package/src/modules/channel_discord/lib/interactions-dispatch.ts +288 -0
  196. package/src/modules/channel_discord/lib/interactions-handler.ts +267 -0
  197. package/src/modules/channel_discord/lib/interactions-queue.ts +52 -0
  198. package/src/modules/channel_discord/lib/interactions-verify.ts +174 -0
  199. package/src/modules/channel_discord/lib/normalize-inbound.ts +89 -0
  200. package/src/modules/channel_discord/lib/preset.ts +109 -0
  201. package/src/modules/channel_discord/lib/queue-strategy.ts +29 -0
  202. package/src/modules/channel_discord/lib/slash-commands.ts +57 -0
  203. package/src/modules/channel_discord/message-types.ts +75 -0
  204. package/src/modules/channel_discord/setup.ts +78 -0
  205. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.armed.test.ts +149 -0
  206. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.failure-marker.test.ts +133 -0
  207. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.test.ts +273 -0
  208. package/src/modules/channel_discord/subscribers/ai-auto-reply.ts +325 -0
  209. package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.tsx +143 -0
  210. package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.ts +19 -0
  211. package/src/modules/channel_discord/widgets/injection/connect/widget.client.tsx +237 -0
  212. package/src/modules/channel_discord/widgets/injection/connect/widget.ts +16 -0
  213. package/src/modules/channel_discord/widgets/injection-table.ts +24 -0
  214. package/src/modules/channel_discord/workers/__tests__/discord-gateway.single-identify.test.ts +88 -0
  215. package/src/modules/channel_discord/workers/__tests__/discord-gateway.test.ts +179 -0
  216. package/src/modules/channel_discord/workers/__tests__/discord-interactions.test.ts +152 -0
  217. package/src/modules/channel_discord/workers/discord-gateway.ts +458 -0
  218. package/src/modules/channel_discord/workers/discord-interactions.ts +162 -0
  219. package/tsconfig.json +9 -0
  220. package/watch.mjs +7 -0
@@ -0,0 +1,288 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useParams } from 'next/navigation'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
7
+ import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
8
+ import { ErrorMessage, LoadingMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'
9
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
10
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
11
+ import { updateCrud } from '@open-mercato/ui/backend/utils/crud'
12
+ import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
13
+
14
+ type EligibleAgent = {
15
+ id: string
16
+ label: string
17
+ description: string
18
+ requiredFeatures: string[]
19
+ /** Whether the auto-reply principal holds the agent's `requiredFeatures`. */
20
+ invocable: boolean
21
+ missingFeatures: string[]
22
+ }
23
+
24
+ type AiAutoReplySettings = {
25
+ channelId: string
26
+ displayName: string
27
+ updatedAt: string | null
28
+ aiAutoReplyEnabled: boolean
29
+ aiAgentId: string | null
30
+ aiAutoReplyLastError: string | null
31
+ aiAutoReplyLastErrorAt: string | null
32
+ defaultAgentId: string
33
+ aiAvailable: boolean
34
+ agents: EligibleAgent[]
35
+ }
36
+
37
+ type FormValues = {
38
+ id: string
39
+ updatedAt: string | null
40
+ aiAutoReplyEnabled: boolean
41
+ aiAgentId: string
42
+ }
43
+
44
+ const CHANNELS_LIST_HREF = '/backend/communication_channels/channels'
45
+
46
+ /**
47
+ * Per-channel AI auto-reply settings (issue #4778) — the surface that writes
48
+ * `aiAutoReplyEnabled` / `aiAgentId`, the two keys the subscriber reads.
49
+ *
50
+ * It is a `CrudForm` rather than a hand-rolled form so the save inherits the
51
+ * platform's contract for free: zod validation with field-level errors, and the
52
+ * optimistic-lock header auto-derived from `initialValues.updatedAt`, so a save
53
+ * from a tab opened before someone else touched the channel surfaces the shared
54
+ * conflict bar instead of overwriting them.
55
+ */
56
+ export default function DiscordAiAutoReplyPage() {
57
+ const t = useT()
58
+ const params = useParams<{ id: string }>()
59
+ const channelId = (params?.id as string) ?? ''
60
+
61
+ const [settings, setSettings] = React.useState<AiAutoReplySettings | null>(null)
62
+ const [isLoading, setIsLoading] = React.useState(true)
63
+ const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
64
+ const [notFound, setNotFound] = React.useState(false)
65
+
66
+ const load = React.useCallback(async () => {
67
+ if (!channelId) return
68
+ setIsLoading(true)
69
+ setErrorMessage(null)
70
+ setNotFound(false)
71
+ const response = await apiCall<AiAutoReplySettings>(
72
+ `/api/channel_discord/channels/${encodeURIComponent(channelId)}/ai-auto-reply`,
73
+ ).catch(() => null)
74
+ if (!response || !response.ok) {
75
+ const status = (response as { status?: number } | null)?.status
76
+ if (status === 404) {
77
+ setNotFound(true)
78
+ } else {
79
+ const body = response?.result as { error?: string } | undefined
80
+ setErrorMessage(
81
+ body?.error ?? t('channel_discord.aiAutoReply.errors.load', 'Failed to load AI auto-reply settings'),
82
+ )
83
+ }
84
+ setSettings(null)
85
+ } else {
86
+ setSettings(response.result ?? null)
87
+ }
88
+ setIsLoading(false)
89
+ }, [channelId, t])
90
+
91
+ React.useEffect(() => {
92
+ void load()
93
+ }, [load])
94
+
95
+ const fields = React.useMemo<CrudField[]>(() => {
96
+ // An agent the auto-reply principal cannot invoke stays in the list — hiding
97
+ // it would leave the operator wondering where an agent they can see elsewhere
98
+ // went — but it says what it needs, so the requirement is visible before the
99
+ // save rather than only in the 400 that follows.
100
+ const agentOptions = (settings?.agents ?? []).map((agent) => ({
101
+ value: agent.id,
102
+ label: agent.invocable
103
+ ? agent.label
104
+ : t(
105
+ 'channel_discord.aiAutoReply.fields.agentMissingFeatures',
106
+ '{label} — needs {features}',
107
+ { label: agent.label, features: agent.missingFeatures.join(', ') },
108
+ ),
109
+ }))
110
+ return [
111
+ {
112
+ id: 'aiAutoReplyEnabled',
113
+ type: 'checkbox',
114
+ label: t('channel_discord.aiAutoReply.fields.enabled', 'Answer inbound Discord messages with AI'),
115
+ description: t(
116
+ 'channel_discord.aiAutoReply.fields.enabledHelp',
117
+ 'Off by default. When on, straightforward messages are answered automatically; anything sensitive, low-confidence or suspicious is proposed for a human to approve instead.',
118
+ ),
119
+ disabled: !settings?.aiAvailable,
120
+ },
121
+ {
122
+ id: 'aiAgentId',
123
+ type: 'select',
124
+ label: t('channel_discord.aiAutoReply.fields.agent', 'Agent'),
125
+ description: t(
126
+ 'channel_discord.aiAutoReply.fields.agentHelp',
127
+ 'Only agents that produce structured output can answer a channel. Grant the tenant channel-bot user the agent’s required features before choosing an agent from another module.',
128
+ ),
129
+ options: agentOptions,
130
+ required: true,
131
+ visibleWhen: { field: 'aiAutoReplyEnabled', equals: true },
132
+ },
133
+ ]
134
+ }, [settings, t])
135
+
136
+ const initialValues = React.useMemo<Partial<FormValues>>(() => {
137
+ if (!settings) return {}
138
+ // Default to an agent the principal can actually invoke, so the form does not
139
+ // pre-select a choice the save will reject.
140
+ const invocable = settings.agents.filter((agent) => agent.invocable)
141
+ const fallbackAgentId = invocable.some((agent) => agent.id === settings.defaultAgentId)
142
+ ? settings.defaultAgentId
143
+ : (invocable[0]?.id ?? settings.agents[0]?.id ?? '')
144
+ return {
145
+ id: settings.channelId,
146
+ updatedAt: settings.updatedAt,
147
+ aiAutoReplyEnabled: settings.aiAutoReplyEnabled,
148
+ aiAgentId: settings.aiAgentId ?? fallbackAgentId,
149
+ }
150
+ }, [settings])
151
+
152
+ const onSubmit = React.useCallback(
153
+ async (values: FormValues) => {
154
+ const enabled = Boolean(values.aiAutoReplyEnabled)
155
+ const agentId = (values.aiAgentId ?? '').trim()
156
+ if (enabled && !agentId) {
157
+ throw createCrudFormError(
158
+ t('channel_discord.aiAutoReply.errors.agentRequired', 'Choose the agent that should answer this channel'),
159
+ { aiAgentId: t('channel_discord.aiAutoReply.errors.agentRequired', 'Choose the agent that should answer this channel') },
160
+ )
161
+ }
162
+ // The route enforces this too — it has to, since a role can change between
163
+ // the load and the save. Checking here as well turns the common case into an
164
+ // inline field error instead of a round trip.
165
+ const chosen = settings?.agents.find((agent) => agent.id === agentId)
166
+ if (enabled && chosen && !chosen.invocable) {
167
+ const message = t(
168
+ 'channel_discord.aiAutoReply.errors.agentFeaturesMissing',
169
+ 'The channel-bot user is missing the features this agent needs: {features}. Grant them first, or pick another agent.',
170
+ { features: chosen.missingFeatures.join(', ') },
171
+ )
172
+ throw createCrudFormError(message, { aiAgentId: message })
173
+ }
174
+ // Deliberately unguarded: a 409 from the optimistic-lock check has to reach
175
+ // CrudForm, which renders it on the shared conflict bar. Catching it here
176
+ // would surface the same bar and then let the form believe the save
177
+ // succeeded.
178
+ await updateCrud(
179
+ `channel_discord/channels/${encodeURIComponent(channelId)}/ai-auto-reply`,
180
+ {
181
+ aiAutoReplyEnabled: enabled,
182
+ aiAgentId: enabled ? agentId : undefined,
183
+ },
184
+ {
185
+ errorMessage: t(
186
+ 'channel_discord.aiAutoReply.errors.save',
187
+ 'Failed to save AI auto-reply settings',
188
+ ),
189
+ },
190
+ )
191
+ flash(t('channel_discord.aiAutoReply.saved', 'AI auto-reply settings saved.'), 'success')
192
+ await load()
193
+ },
194
+ [channelId, load, settings, t],
195
+ )
196
+
197
+ if (isLoading) {
198
+ return (
199
+ <Page>
200
+ <PageBody>
201
+ <LoadingMessage label={t('channel_discord.aiAutoReply.loading', 'Loading AI auto-reply settings...')} />
202
+ </PageBody>
203
+ </Page>
204
+ )
205
+ }
206
+
207
+ if (notFound) {
208
+ return (
209
+ <Page>
210
+ <PageBody>
211
+ <RecordNotFoundState
212
+ label={t('channel_discord.aiAutoReply.notFound', 'Discord channel not found')}
213
+ backHref={CHANNELS_LIST_HREF}
214
+ />
215
+ </PageBody>
216
+ </Page>
217
+ )
218
+ }
219
+
220
+ if (errorMessage || !settings) {
221
+ return (
222
+ <Page>
223
+ <PageBody>
224
+ <ErrorMessage
225
+ label={
226
+ errorMessage
227
+ ?? t('channel_discord.aiAutoReply.errors.load', 'Failed to load AI auto-reply settings')
228
+ }
229
+ />
230
+ </PageBody>
231
+ </Page>
232
+ )
233
+ }
234
+
235
+ return (
236
+ <Page>
237
+ <PageBody>
238
+ <CrudForm<FormValues>
239
+ title={t('channel_discord.aiAutoReply.page.title', 'Discord AI auto-reply')}
240
+ backHref={CHANNELS_LIST_HREF}
241
+ cancelHref={CHANNELS_LIST_HREF}
242
+ fields={fields}
243
+ initialValues={initialValues}
244
+ onSubmit={onSubmit}
245
+ submitLabel={t('channel_discord.aiAutoReply.save', 'Save')}
246
+ contentHeader={
247
+ <div className="mb-4 space-y-2 text-sm text-muted-foreground">
248
+ <p>
249
+ {t(
250
+ 'channel_discord.aiAutoReply.intro',
251
+ 'Choose whether this Discord channel may answer on its own, and which agent drafts the reply.',
252
+ )}{' '}
253
+ <span className="font-medium text-foreground">{settings.displayName}</span>
254
+ </p>
255
+ {settings.aiAvailable ? null : (
256
+ <p>
257
+ {t(
258
+ 'channel_discord.aiAutoReply.unavailable',
259
+ 'The AI assistant module is not installed in this deployment, so auto-reply cannot be enabled. The channel keeps working as a normal inbox.',
260
+ )}
261
+ </p>
262
+ )}
263
+ {settings.aiAutoReplyLastError ? (
264
+ // An armed channel whose runtime call keeps being refused is the
265
+ // dormancy failure wearing a green tag. It is reported here, on the
266
+ // surface that armed it, rather than only in a subscriber log line.
267
+ <p className="rounded-md border border-status-error-border bg-status-error-bg p-3 text-status-error-text">
268
+ {t(
269
+ 'channel_discord.aiAutoReply.lastError',
270
+ 'The last auto-reply attempt on this channel produced nothing: {reason}',
271
+ { reason: settings.aiAutoReplyLastError },
272
+ )}
273
+ {settings.aiAutoReplyLastErrorAt ? (
274
+ <span className="ml-1">
275
+ {t('channel_discord.aiAutoReply.lastErrorAt', '(first seen {at})', {
276
+ at: new Date(settings.aiAutoReplyLastErrorAt).toLocaleString(),
277
+ })}
278
+ </span>
279
+ ) : null}
280
+ </p>
281
+ ) : null}
282
+ </div>
283
+ }
284
+ />
285
+ </PageBody>
286
+ </Page>
287
+ )
288
+ }
@@ -0,0 +1,234 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { ModuleCli } from '@open-mercato/shared/modules/registry'
4
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
+ import { createLogger } from '@open-mercato/shared/lib/logger'
6
+ import { createCredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'
7
+ import { createIntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
8
+ import { parseDiscordCredentialsOrThrow } from './lib/credentials'
9
+ import { getDiscordRestClient } from './lib/discord-rest'
10
+ import { applyDiscordEnvPreset } from './lib/preset'
11
+ import { assertInboundDeliverable } from './lib/queue-strategy'
12
+ import { DISCORD_DEFAULT_SLASH_COMMANDS, parseSlashCommandDefinitions } from './lib/slash-commands'
13
+ import gatewayHandle, { CHANNEL_DISCORD_GATEWAY_QUEUE } from './workers/discord-gateway'
14
+
15
+ const logger = createLogger('channel_discord').child({ component: 'cli' })
16
+
17
+ /**
18
+ * Split `--key value` pairs from bare `--flag` switches so a boolean flag does
19
+ * not shift the positional pairing of the remaining arguments.
20
+ */
21
+ export function parseFlagsAndValues(args: string[]): { flags: Set<string>; values: Record<string, string> } {
22
+ const flags = new Set<string>()
23
+ const values: Record<string, string> = {}
24
+ for (let index = 0; index < args.length; index += 1) {
25
+ const token = args[index]
26
+ if (!token?.startsWith('-')) continue
27
+ const key = token.replace(/^-+/, '')
28
+ const next = args[index + 1]
29
+ if (next && !next.startsWith('-')) {
30
+ values[key] = next
31
+ index += 1
32
+ } else {
33
+ flags.add(key)
34
+ }
35
+ }
36
+ return { flags, values }
37
+ }
38
+
39
+ /**
40
+ * Start the Discord Gateway bridge.
41
+ *
42
+ * WHY THIS EXISTS: the provider advertises `realtimePush: true`, so the hub's
43
+ * polling scheduler skips Discord channels — inbound relies entirely on this
44
+ * long-running gateway worker. Nothing enqueues the `channel_discord_gateway`
45
+ * job automatically (no hub "channel connected" event exists), so inbound is
46
+ * dead until an operator starts this process. This command enqueues the initial
47
+ * bootstrap job, runs the worker, and (by default) re-enqueues a refresh job on
48
+ * an interval so newly connected channels are picked up and deactivated /
49
+ * soft-deleted channels are reconciled away (sockets closed).
50
+ *
51
+ * The refresh job is a reconciler, not a re-connector: the worker leaves every
52
+ * channel whose gateway session is still running untouched, so a short interval
53
+ * costs one query per tick and never disturbs a healthy socket.
54
+ *
55
+ * Usage:
56
+ * mercato channel_discord start-gateway [--tenant <tenantId>] [--refresh <seconds>]
57
+ *
58
+ * `--refresh 0` disables the periodic refresh (single reconciliation at startup).
59
+ * Set `OM_CHANNEL_DISCORD_GATEWAY_DISABLED=1` to make the worker a no-op.
60
+ */
61
+ const startGateway: ModuleCli = {
62
+ command: 'start-gateway',
63
+ async run(rest: string[]) {
64
+ const { values } = parseFlagsAndValues(rest)
65
+ const tenantId = values.tenant ?? values.tenantId ?? values.t
66
+ const refreshSeconds = Number.isFinite(Number(values.refresh ?? values.r))
67
+ ? Math.max(0, Number(values.refresh ?? values.r))
68
+ : 60
69
+
70
+ assertInboundDeliverable(process.env.QUEUE_STRATEGY)
71
+
72
+ logger.info('starting Discord gateway bridge', {
73
+ queue: CHANNEL_DISCORD_GATEWAY_QUEUE,
74
+ tenantId: tenantId ?? 'all',
75
+ refreshSeconds,
76
+ })
77
+
78
+ const container = await createRequestContainer()
79
+
80
+ const { createModuleQueue, runWorker } = await import('@open-mercato/queue')
81
+
82
+ const jobPayload = tenantId ? { tenantId } : {}
83
+
84
+ // The worker context only needs `.resolve`; the queue runner's own ctx does
85
+ // not carry the DI container, so we close over the request container here
86
+ // (mirrors workflows' `start-worker`).
87
+ const handler = async (job: { payload?: Record<string, unknown> }): Promise<void> => {
88
+ await gatewayHandle(job as never, { resolve: (name: string) => container.resolve(name) } as never)
89
+ }
90
+
91
+ await runWorker({
92
+ queueName: CHANNEL_DISCORD_GATEWAY_QUEUE,
93
+ handler: handler as never,
94
+ concurrency: 1,
95
+ gracefulShutdown: true,
96
+ background: true,
97
+ })
98
+
99
+ const queue = createModuleQueue<Record<string, unknown>>(CHANNEL_DISCORD_GATEWAY_QUEUE, { concurrency: 1 })
100
+ await queue.enqueue({ ...jobPayload, reason: 'startup' })
101
+ logger.info('enqueued gateway bootstrap job')
102
+
103
+ if (refreshSeconds > 0) {
104
+ setInterval(() => {
105
+ queue
106
+ .enqueue({ ...jobPayload, reason: 'refresh' })
107
+ .catch((err) => logger.warn('failed to enqueue gateway refresh job', { err }))
108
+ }, refreshSeconds * 1000)
109
+ }
110
+
111
+ logger.info('Discord gateway bridge running — press Ctrl+C to stop')
112
+ // Keep the process alive so the sockets + refresh interval persist.
113
+ await new Promise<void>(() => {})
114
+ },
115
+ }
116
+
117
+ /**
118
+ * Rerun the provider-owned env preconfiguration for one tenant + organization.
119
+ *
120
+ * `setup.ts` applies the same preset when a tenant is created; this command
121
+ * exists so an operator can apply it to tenants that already existed when the
122
+ * `OM_CHANNEL_DISCORD_*` vars were introduced, or re-apply it after rotating the
123
+ * bot token (with `--force`). Idempotent: without `--force` an existing
124
+ * credential record is left untouched.
125
+ */
126
+ const configureFromEnv: ModuleCli = {
127
+ command: 'configure-from-env',
128
+ async run(rest: string[]) {
129
+ const { flags, values } = parseFlagsAndValues(rest)
130
+ const tenantId = values.tenant ?? values.tenantId
131
+ const organizationId = values.org ?? values.organization ?? values.organizationId
132
+
133
+ if (!tenantId || !organizationId) {
134
+ logger.error('configure-from-env requires --tenant <tenantId> --org <organizationId>')
135
+ throw new Error('[internal] channel_discord configure-from-env requires --tenant and --org')
136
+ }
137
+
138
+ const container = await createRequestContainer()
139
+ const em = container.resolve('em') as EntityManager
140
+ const result = await applyDiscordEnvPreset({
141
+ credentialsService: createCredentialsService(em),
142
+ integrationLogService: createIntegrationLogService(em),
143
+ scope: { tenantId, organizationId },
144
+ force: flags.has('force'),
145
+ })
146
+
147
+ if (result.status === 'configured') {
148
+ logger.info('Discord integration preconfigured from environment', { tenantId, organizationId })
149
+ } else {
150
+ logger.info('Discord env preconfiguration skipped', { tenantId, organizationId, reason: result.reason })
151
+ }
152
+ },
153
+ }
154
+
155
+ /**
156
+ * Register the provider's application (slash) commands against one guild.
157
+ *
158
+ * WHY IT EXISTS: a slash command only appears in Discord once the application
159
+ * has registered it. Nothing does that automatically — registration is a
160
+ * per-guild, operator-scoped decision — so without this command the Interactions
161
+ * endpoint is reachable but nobody can produce an interaction to send to it.
162
+ *
163
+ * Guild-scoped (`PUT /applications/{app}/guilds/{guild}/commands`) rather than
164
+ * global on purpose: guild registrations take effect immediately, while global
165
+ * ones take up to an hour to propagate, and the provider already recommends
166
+ * scoping the bot to one server.
167
+ *
168
+ * The call REPLACES the guild's command list for this application, so it is
169
+ * idempotent — re-running it converges rather than accumulating duplicates.
170
+ *
171
+ * Usage:
172
+ * mercato channel_discord register-slash-commands --tenant <id> --org <id>
173
+ * [--guild <guildId>] [--commands <path-to-json>]
174
+ *
175
+ * `--guild` overrides the `guildId` credential; `--commands` registers an
176
+ * operator-authored definition array instead of the shipped default.
177
+ */
178
+ const registerSlashCommands: ModuleCli = {
179
+ command: 'register-slash-commands',
180
+ async run(rest: string[]) {
181
+ const { values } = parseFlagsAndValues(rest)
182
+ const tenantId = values.tenant ?? values.tenantId
183
+ const organizationId = values.org ?? values.organization ?? values.organizationId
184
+
185
+ if (!tenantId || !organizationId) {
186
+ logger.error('register-slash-commands requires --tenant <tenantId> --org <organizationId>')
187
+ throw new Error('[internal] channel_discord register-slash-commands requires --tenant and --org')
188
+ }
189
+
190
+ const container = await createRequestContainer()
191
+ const em = container.resolve('em') as EntityManager
192
+ const credentials = await createCredentialsService(em).resolve('channel_discord', {
193
+ tenantId,
194
+ organizationId,
195
+ })
196
+ if (!credentials) {
197
+ throw new Error(
198
+ `[internal] No Discord credentials for tenant ${tenantId} / organization ${organizationId}. ` +
199
+ 'Connect the channel in /backend/integrations first.',
200
+ )
201
+ }
202
+ const parsed = parseDiscordCredentialsOrThrow(credentials)
203
+
204
+ const guildId = values.guild ?? values.guildId ?? parsed.guildId
205
+ if (!guildId) {
206
+ throw new Error(
207
+ '[internal] channel_discord register-slash-commands needs a guild: set the guildId credential ' +
208
+ 'or pass --guild <guildId>.',
209
+ )
210
+ }
211
+
212
+ const commands = values.commands
213
+ ? parseSlashCommandDefinitions(JSON.parse(await readFile(values.commands, 'utf-8')))
214
+ : DISCORD_DEFAULT_SLASH_COMMANDS
215
+
216
+ await getDiscordRestClient().registerGuildCommands(
217
+ { botToken: parsed.botToken },
218
+ parsed.applicationId,
219
+ guildId,
220
+ commands,
221
+ )
222
+
223
+ logger.info('registered Discord guild slash commands', {
224
+ tenantId,
225
+ organizationId,
226
+ guildId,
227
+ commands: commands.map((command) => command.name),
228
+ })
229
+ },
230
+ }
231
+
232
+ const channelDiscordCliCommands = [startGateway, configureFromEnv, registerSlashCommands]
233
+
234
+ export default channelDiscordCliCommands
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The approval surface's two commands (issue #4778).
3
+ *
4
+ * The propose-only guarantee has two halves. The subscriber owns "never send
5
+ * without a human" (covered in `subscribers/__tests__/ai-auto-reply.test.ts`);
6
+ * these commands own "when a human does approve, send exactly what they saw, in
7
+ * the right conversation, attributed to them" — and refuse to send anything else.
8
+ */
9
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
10
+ findOneWithDecryption: jest.fn(),
11
+ }))
12
+
13
+ import { approveProposalCommand, dismissProposalCommand } from '../ai-reply-proposal'
14
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
15
+ import { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from '../../message-types'
16
+
17
+ const findOne = findOneWithDecryption as unknown as jest.Mock
18
+
19
+ const TENANT = '11111111-1111-4111-8111-111111111111'
20
+ const ORG = '22222222-2222-4222-8222-222222222222'
21
+ const OPERATOR = '44444444-4444-4444-8444-444444444444'
22
+ const PROPOSAL_ID = '55555555-5555-4555-8555-555555555555'
23
+ const INBOUND_ID = '66666666-6666-4666-8666-666666666666'
24
+
25
+ function makeCtx() {
26
+ const em = { fork: () => ({}) }
27
+ const commandBus = { execute: jest.fn(async () => ({ result: { id: 'sent-1', threadId: 'thread-1' } })) }
28
+ return {
29
+ ctx: {
30
+ container: { resolve: (name: string) => (name === 'em' ? em : commandBus) },
31
+ auth: { sub: OPERATOR, tenantId: TENANT, orgId: ORG },
32
+ organizationScope: null,
33
+ selectedOrganizationId: ORG,
34
+ organizationIds: [ORG],
35
+ } as never,
36
+ commandBus,
37
+ }
38
+ }
39
+
40
+ function proposalRow(overrides: Record<string, unknown> = {}) {
41
+ return {
42
+ id: PROPOSAL_ID,
43
+ type: CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE,
44
+ body: 'We open at 9 and close at 5.',
45
+ sourceEntityId: INBOUND_ID,
46
+ ...overrides,
47
+ }
48
+ }
49
+
50
+ function inboundRow() {
51
+ return {
52
+ id: INBOUND_ID,
53
+ type: 'channel.discord',
54
+ subject: 'Opening hours',
55
+ threadId: 'thread-1',
56
+ }
57
+ }
58
+
59
+ const actionInput = { messageId: PROPOSAL_ID, actionId: 'approve_send' }
60
+
61
+ describe('channel_discord.ai_reply_proposal.approve', () => {
62
+ beforeEach(() => {
63
+ findOne.mockReset()
64
+ })
65
+
66
+ it('sends the proposed text into the original thread, attributed to the approver', async () => {
67
+ findOne.mockResolvedValueOnce(proposalRow()).mockResolvedValueOnce(inboundRow())
68
+ const { ctx, commandBus } = makeCtx()
69
+
70
+ const result = await approveProposalCommand.execute(actionInput as never, ctx)
71
+
72
+ expect(result).toMatchObject({ ok: true, sentMessageId: 'sent-1' })
73
+ const [commandId, args] = commandBus.execute.mock.calls[0]
74
+ expect(commandId).toBe('messages.messages.compose')
75
+ expect(args.input).toMatchObject({
76
+ body: 'We open at 9 and close at 5.',
77
+ visibility: 'public',
78
+ isDraft: false,
79
+ parentMessageId: 'thread-1',
80
+ // The human who pressed approve owns the send, not the bot.
81
+ userId: OPERATOR,
82
+ tenantId: TENANT,
83
+ organizationId: ORG,
84
+ })
85
+ })
86
+
87
+ it('refuses to send a message that is not one of its own proposals', async () => {
88
+ findOne.mockResolvedValueOnce(proposalRow({ type: 'channel.discord' }))
89
+ const { ctx, commandBus } = makeCtx()
90
+
91
+ await expect(approveProposalCommand.execute(actionInput as never, ctx)).rejects.toThrow(
92
+ /not a Discord AI reply proposal/i,
93
+ )
94
+ expect(commandBus.execute).not.toHaveBeenCalled()
95
+ })
96
+
97
+ it('refuses when the conversation it answers is gone', async () => {
98
+ findOne.mockResolvedValueOnce(proposalRow()).mockResolvedValueOnce(null)
99
+ const { ctx, commandBus } = makeCtx()
100
+
101
+ await expect(approveProposalCommand.execute(actionInput as never, ctx)).rejects.toThrow(
102
+ /no longer exists/i,
103
+ )
104
+ expect(commandBus.execute).not.toHaveBeenCalled()
105
+ })
106
+
107
+ it('refuses an empty proposal rather than posting a blank Discord message', async () => {
108
+ findOne.mockResolvedValueOnce(proposalRow({ body: ' ' })).mockResolvedValueOnce(inboundRow())
109
+ const { ctx, commandBus } = makeCtx()
110
+
111
+ await expect(approveProposalCommand.execute(actionInput as never, ctx)).rejects.toThrow(/empty body/i)
112
+ expect(commandBus.execute).not.toHaveBeenCalled()
113
+ })
114
+
115
+ it('requires an authenticated actor — nothing approves itself', async () => {
116
+ const { ctx } = makeCtx()
117
+ const anonymous = { ...(ctx as unknown as Record<string, unknown>), auth: null } as never
118
+
119
+ await expect(approveProposalCommand.execute(actionInput as never, anonymous)).rejects.toThrow(
120
+ /authenticated actor/i,
121
+ )
122
+ })
123
+
124
+ it('declares itself non-undoable — a posted Discord message cannot be recalled', () => {
125
+ expect(approveProposalCommand.isUndoable).toBe(false)
126
+ })
127
+ })
128
+
129
+ describe('channel_discord.ai_reply_proposal.dismiss', () => {
130
+ beforeEach(() => {
131
+ findOne.mockReset()
132
+ })
133
+
134
+ it('acknowledges without sending anything', async () => {
135
+ findOne.mockResolvedValueOnce(proposalRow())
136
+ const { ctx, commandBus } = makeCtx()
137
+
138
+ const result = await dismissProposalCommand.execute(
139
+ { messageId: PROPOSAL_ID, actionId: 'dismiss' } as never,
140
+ ctx,
141
+ )
142
+
143
+ expect(result).toEqual({ ok: true, dismissed: true })
144
+ expect(commandBus.execute).not.toHaveBeenCalled()
145
+ })
146
+
147
+ it('refuses to acknowledge a message that is not one of its own proposals', async () => {
148
+ findOne.mockResolvedValueOnce(proposalRow({ type: 'channel.discord' }))
149
+ const { ctx } = makeCtx()
150
+
151
+ await expect(
152
+ dismissProposalCommand.execute({ messageId: PROPOSAL_ID, actionId: 'dismiss' } as never, ctx),
153
+ ).rejects.toThrow(/not a Discord AI reply proposal/i)
154
+ })
155
+ })