@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,325 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CommandBus } from '@open-mercato/shared/lib/commands'
3
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { createLogger } from '@open-mercato/shared/lib/logger'
5
+ import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
6
+ import { Message } from '@open-mercato/core/modules/messages/data/entities'
7
+ import { resolveCommunicationChannelsSystemUserId } from '@open-mercato/core/modules/communication_channels/lib/system-user'
8
+ import {
9
+ CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID,
10
+ discordAutoReplyOutputSchema,
11
+ type DiscordAutoReplyOutput,
12
+ } from '../ai-agents'
13
+ import {
14
+ classifyDiscordMessage,
15
+ hasStoredAutoReplyFailure,
16
+ isAiAssistantAvailable,
17
+ isAiAutoReplyEnabled,
18
+ resolveAiAgentId,
19
+ type SubscriberResolver,
20
+ } from '../lib/ai-reply'
21
+ import { fileDiscordReplyProposal } from '../lib/ai-proposal'
22
+ import { resolveDiscordAiPrincipal, type DiscordAiPrincipal } from '../lib/ai-service-principal'
23
+ import { recordDiscordAutoReplyOutcome } from '../lib/channel-state-store'
24
+ import { describeAgentFailure } from '../lib/failure-reason'
25
+
26
+ const logger = createLogger('channel_discord').child({ component: 'ai-auto-reply' })
27
+
28
+ /**
29
+ * AI auto-reply subscriber (SPEC 2026-06-19 § AI bot wiring, issue #4778).
30
+ *
31
+ * Listens to `communication_channels.message.received`, filters to Discord, and —
32
+ * only when the channel opted in (default OFF) AND the optional `ai_assistant`
33
+ * peer is present — drafts a reply through the programmatic agent runtime. An
34
+ * "easy" message the model is confident about is answered directly through the
35
+ * generic hub outbound path (compose → outbound-bridge → `deliver_outbound` →
36
+ * Discord REST). Anything else becomes a proposal a human approves. Nothing
37
+ * Discord-specific leaks into the send path; any module could do the same.
38
+ *
39
+ * How the guarantees actually hold, rather than by convention:
40
+ *
41
+ * - `ai_assistant` is an OPTIONAL peer resolved softly; absent → no-op, and the
42
+ * channel keeps working as a plain inbox.
43
+ * - The agent runs under a real, tenant-scoped identity with real `features`
44
+ * (`lib/ai-service-principal.ts`), never `features: []` and never as a
45
+ * super-admin. `runAiAgentObject` enforces the agent's `requiredFeatures`,
46
+ * `executionMode` and `mutationPolicy` against it; this subscriber cannot
47
+ * widen them.
48
+ * - The agent is object-mode, so the runtime never exposes a tool to the model
49
+ * — no privileged action is reachable from an inbound Discord message at all,
50
+ * let alone auto-executable.
51
+ * - Auto-send needs THREE independent yeses: the conservative regex tiering
52
+ * says `easy`, the model says it does not need a human, and the model's own
53
+ * confidence clears {@link AUTO_SEND_MIN_CONFIDENCE}. Any single no routes the
54
+ * draft to the approval surface instead.
55
+ * - Every failure degrades to a no-op. A broken model, a denied policy check, a
56
+ * malformed object — none of them can turn into a send.
57
+ */
58
+ export const metadata = {
59
+ event: 'communication_channels.message.received',
60
+ persistent: true,
61
+ id: 'channel_discord:ai-auto-reply',
62
+ }
63
+
64
+ /**
65
+ * Below this, the model's own draft is proposed rather than sent. The number is
66
+ * deliberately high: the cost of a wrong auto-answer in a public Discord server
67
+ * is much higher than the cost of a human glancing at a proposal.
68
+ */
69
+ export const AUTO_SEND_MIN_CONFIDENCE = 0.6
70
+
71
+ type MessageReceivedPayload = {
72
+ messageId?: string
73
+ channelId?: string
74
+ conversationId?: string
75
+ providerKey?: string
76
+ channelType?: string
77
+ direction?: string
78
+ tenantId?: string
79
+ organizationId?: string | null
80
+ }
81
+
82
+ type Ctx = SubscriberResolver
83
+
84
+ function resolveFromCtx<T = unknown>(ctx: Ctx, name: string): T {
85
+ if (typeof ctx?.resolve === 'function') return ctx.resolve<T>(name)
86
+ if (ctx?.container && typeof ctx.container.resolve === 'function') return ctx.container.resolve<T>(name)
87
+ throw new Error(`[internal] channel_discord ai-auto-reply: no resolver for '${name}'`)
88
+ }
89
+
90
+ /** Minimal shape of the optional `@open-mercato/ai-assistant` peer we call. */
91
+ interface AiAssistantModule {
92
+ runAiAgentObject: (input: {
93
+ agentId: string
94
+ input: string
95
+ authContext: Record<string, unknown>
96
+ container: unknown
97
+ sessionId?: string
98
+ }) => Promise<{ mode: string; object: unknown }>
99
+ }
100
+
101
+ export default async function handler(payload: MessageReceivedPayload, ctx: Ctx): Promise<void> {
102
+ // (1) Filter — Discord inbound only.
103
+ if (payload?.providerKey !== 'discord') return
104
+ if (payload?.direction && payload.direction !== 'inbound') return
105
+ if (!payload?.messageId || !payload?.channelId || !payload?.tenantId) return
106
+
107
+ const em = resolveFromCtx<EntityManager>(ctx, 'em').fork()
108
+ const scope = { tenantId: payload.tenantId, organizationId: payload.organizationId ?? null }
109
+
110
+ const channel = await findOneWithDecryption(
111
+ em,
112
+ CommunicationChannel,
113
+ { id: payload.channelId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
114
+ undefined,
115
+ scope,
116
+ )
117
+ if (!channel) return
118
+
119
+ // (2) Per-channel opt-in (default OFF), written by the settings surface at
120
+ // `/backend/channel_discord/channels/<id>/ai-auto-reply`.
121
+ if (!isAiAutoReplyEnabled(channel.channelState)) return
122
+
123
+ // (3) Soft-resolve the optional AI peer — no-op when absent (module-decoupling).
124
+ if (!isAiAssistantAvailable(ctx)) {
125
+ logger.debug('ai_assistant peer unavailable — skipping auto-reply (channel still works as inbox)')
126
+ return
127
+ }
128
+
129
+ // The settings form always stores an explicit agent id; the fallback covers a
130
+ // channel armed by an older preset or by hand.
131
+ const agentId = resolveAiAgentId(channel.channelState) ?? CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID
132
+
133
+ const message = await findOneWithDecryption(
134
+ em,
135
+ Message,
136
+ { id: payload.messageId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
137
+ undefined,
138
+ scope,
139
+ )
140
+ if (!message) return
141
+ const body = (message.body ?? '').toString()
142
+
143
+ // (4) Classify — easy vs complex. Conservative by construction: it can only
144
+ // ever escalate a message away from auto-send.
145
+ const classification = classifyDiscordMessage(body)
146
+
147
+ try {
148
+ await draftAndRoute({
149
+ ctx,
150
+ em,
151
+ message,
152
+ channelId: payload.channelId,
153
+ conversationId: payload.conversationId ?? null,
154
+ agentId,
155
+ body,
156
+ scope,
157
+ tier: classification.tier,
158
+ classificationReason: classification.reason,
159
+ })
160
+ // Clearing costs a read-modify-write, so only pay it on a channel that is
161
+ // actually carrying a stale failure. The row was already loaded above.
162
+ if (hasStoredAutoReplyFailure(channel.channelState)) {
163
+ await recordAutoReplyOutcome({ em, channelId: payload.channelId, scope, failure: null })
164
+ }
165
+ } catch (err) {
166
+ // The no-op is the correct behaviour — a broken model must never become a
167
+ // send. Staying silent about it is not: the operator's surface would keep
168
+ // reading "Auto-reply on" for a channel that answers nothing. Park the reason
169
+ // on the channel so the settings page and the integration panel can show it.
170
+ logger.warn('discord AI auto-reply failed — degrading to no-op', { channelId: payload.channelId, err })
171
+ await recordAutoReplyOutcome({
172
+ em,
173
+ channelId: payload.channelId,
174
+ scope,
175
+ failure: describeAgentFailure(agentId, err),
176
+ })
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Persisting the outcome must never be able to turn a degraded no-op into a
182
+ * thrown handler — the marker is diagnostics, not the job.
183
+ */
184
+ async function recordAutoReplyOutcome(params: {
185
+ em: EntityManager
186
+ channelId: string
187
+ scope: { tenantId: string; organizationId: string | null }
188
+ failure: string | null
189
+ }): Promise<void> {
190
+ try {
191
+ await recordDiscordAutoReplyOutcome(params)
192
+ } catch (err) {
193
+ logger.warn('failed to persist the discord auto-reply outcome marker', {
194
+ channelId: params.channelId,
195
+ err,
196
+ })
197
+ }
198
+ }
199
+
200
+ async function draftAndRoute(args: {
201
+ ctx: Ctx
202
+ em: EntityManager
203
+ message: Message
204
+ channelId: string
205
+ conversationId: string | null
206
+ agentId: string
207
+ body: string
208
+ scope: { tenantId: string; organizationId: string | null }
209
+ tier: 'easy' | 'complex'
210
+ classificationReason: string
211
+ }): Promise<void> {
212
+ const { ctx, em, message, channelId, conversationId, agentId, body, scope, tier, classificationReason } = args
213
+
214
+ const principal = await resolveDiscordAiPrincipal({ em, resolver: { resolve: (name) => resolveFromCtx(ctx, name) }, scope })
215
+
216
+ const draft = await runAutoReplyAgent({ ctx, agentId, body, principal, sessionId: message.threadId ?? message.id })
217
+ if (!draft) return
218
+
219
+ const reply = draft.reply.trim()
220
+ if (!reply) return
221
+
222
+ const autoSend =
223
+ tier === 'easy' && !draft.requiresHuman && draft.confidence >= AUTO_SEND_MIN_CONFIDENCE
224
+ const commandBus = resolveFromCtx<CommandBus>(ctx, 'commandBus')
225
+ const containerCtx = {
226
+ container: { resolve: (name: string) => resolveFromCtx(ctx, name) },
227
+ auth: null,
228
+ organizationScope: null,
229
+ selectedOrganizationId: scope.organizationId,
230
+ organizationIds: scope.organizationId ? [scope.organizationId] : null,
231
+ }
232
+ const botUserId = await resolveCommunicationChannelsSystemUserId(em, scope.tenantId, null)
233
+
234
+ if (!autoSend) {
235
+ const reason = draft.requiresHuman
236
+ ? 'model-requested-review'
237
+ : draft.confidence < AUTO_SEND_MIN_CONFIDENCE
238
+ ? `low-confidence:${draft.confidence.toFixed(2)}`
239
+ : classificationReason
240
+ await fileDiscordReplyProposal({
241
+ em,
242
+ commandBus,
243
+ containerCtx,
244
+ scope,
245
+ channelId,
246
+ inboundMessage: message,
247
+ externalConversationId: conversationId,
248
+ botUserId,
249
+ proposedReply: reply,
250
+ summary: draft.summary,
251
+ reason,
252
+ })
253
+ return
254
+ }
255
+
256
+ // Easy + confident → answer directly. The hub's outbound-bridge subscriber
257
+ // picks up `messages.message.sent`, resolves the Discord channel via the
258
+ // ChannelThreadMapping, and delivers through `deliver_outbound` → `sendMessage`.
259
+ // No direct Discord call here — keep the send path generic and audited.
260
+ await commandBus.execute('messages.messages.compose', {
261
+ input: {
262
+ type: 'channel.discord',
263
+ visibility: 'public' as const,
264
+ subject: (message.subject ?? 'Discord reply').toString().slice(0, 200) || 'Discord reply',
265
+ body: reply,
266
+ bodyFormat: 'markdown' as const,
267
+ priority: 'normal' as const,
268
+ sendViaEmail: false,
269
+ parentMessageId: message.threadId ?? message.id,
270
+ isDraft: false,
271
+ tenantId: scope.tenantId,
272
+ organizationId: scope.organizationId,
273
+ userId: botUserId,
274
+ },
275
+ ctx: containerCtx as never,
276
+ })
277
+ }
278
+
279
+ async function runAutoReplyAgent(args: {
280
+ ctx: Ctx
281
+ agentId: string
282
+ body: string
283
+ principal: DiscordAiPrincipal
284
+ sessionId: string
285
+ }): Promise<DiscordAutoReplyOutput | null> {
286
+ const { agentId, body, principal, sessionId } = args
287
+
288
+ // Dynamic import keeps `ai_assistant` a truly optional peer — when the package
289
+ // is not installed the import throws and we no-op (already gated by the DI
290
+ // presence check in the handler, so this only runs when the peer is active).
291
+ const mod = (await import('@open-mercato/ai-assistant')) as unknown as AiAssistantModule
292
+ if (typeof mod.runAiAgentObject !== 'function') return null
293
+
294
+ // The runtime only touches `container.resolve(...)`; a proxy over the
295
+ // subscriber resolver satisfies that without depending on a concrete DI name
296
+ // (mirrors the inbound-processor's `containerProxy` pattern).
297
+ const container = { resolve: (name: string) => resolveFromCtx(args.ctx, name) }
298
+
299
+ // No `output` override: the agent declares its own schema, so the schema the
300
+ // policy layer validated the agent against is the schema the model is held to.
301
+ const result = await mod.runAiAgentObject({
302
+ agentId,
303
+ input: body,
304
+ authContext: {
305
+ tenantId: principal.tenantId,
306
+ organizationId: principal.organizationId,
307
+ userId: principal.userId,
308
+ features: principal.features,
309
+ isSuperAdmin: principal.isSuperAdmin,
310
+ },
311
+ container,
312
+ // Preserve multi-turn context per conversation thread.
313
+ sessionId,
314
+ })
315
+
316
+ const parsed = discordAutoReplyOutputSchema.safeParse((result as { object?: unknown }).object)
317
+ if (!parsed.success) {
318
+ logger.warn('auto-reply agent returned an object that does not match the declared schema', {
319
+ agentId,
320
+ issues: parsed.error.issues.map((issue) => issue.path.join('.')),
321
+ })
322
+ return null
323
+ }
324
+ return parsed.data
325
+ }
@@ -0,0 +1,143 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import Link from 'next/link'
5
+ import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import { Button } from '@open-mercato/ui/primitives/button'
9
+ import { Tag } from '@open-mercato/ui/primitives/tag'
10
+ import { ErrorMessage, LoadingMessage } from '@open-mercato/ui/backend/detail'
11
+
12
+ type ChannelEntry = {
13
+ channelId: string
14
+ displayName: string
15
+ aiAutoReplyEnabled: boolean
16
+ aiAgentId: string | null
17
+ aiAutoReplyLastError: string | null
18
+ aiAutoReplyLastErrorAt: string | null
19
+ }
20
+
21
+ type ChannelsResponse = {
22
+ items?: ChannelEntry[]
23
+ truncated?: boolean
24
+ }
25
+
26
+ /**
27
+ * Entry point to the per-channel AI auto-reply settings, rendered on the Discord
28
+ * integration's detail page (issue #4778).
29
+ *
30
+ * It lives here rather than as a row action on the hub's channel table because
31
+ * injected row actions render on EVERY row of that table — a "Discord AI
32
+ * auto-reply" entry would then appear on Gmail and IMAP channels too. The
33
+ * integration detail spot is Discord-scoped by construction, which is exactly the
34
+ * scoping this affordance needs.
35
+ */
36
+ export default function DiscordAiAutoReplyWidget(
37
+ _props: InjectionWidgetComponentProps<Record<string, unknown>, Record<string, unknown>>,
38
+ ) {
39
+ const t = useT()
40
+ const [entries, setEntries] = React.useState<ChannelEntry[] | null>(null)
41
+ const [truncated, setTruncated] = React.useState(false)
42
+ const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
43
+
44
+ React.useEffect(() => {
45
+ let cancelled = false
46
+ async function load() {
47
+ // One call for the whole panel. Listing the channels and then asking the
48
+ // per-channel settings route for each one was a request per channel, and
49
+ // every one of those responses rebuilt the agent registry to render two
50
+ // booleans.
51
+ const listed = await apiCall<ChannelsResponse>(
52
+ '/api/channel_discord/ai-auto-reply/channels',
53
+ ).catch(() => null)
54
+ if (cancelled) return
55
+ if (!listed?.ok) {
56
+ setErrorMessage(
57
+ t('channel_discord.aiAutoReply.errors.loadChannels', 'Failed to load Discord channels'),
58
+ )
59
+ setEntries([])
60
+ return
61
+ }
62
+ setEntries(listed.result?.items ?? [])
63
+ setTruncated(Boolean(listed.result?.truncated))
64
+ }
65
+ void load()
66
+ return () => {
67
+ cancelled = true
68
+ }
69
+ }, [t])
70
+
71
+ if (errorMessage) return <ErrorMessage label={errorMessage} />
72
+ if (entries === null) {
73
+ return <LoadingMessage label={t('channel_discord.aiAutoReply.loadingChannels', 'Loading Discord channels...')} />
74
+ }
75
+
76
+ return (
77
+ <div className="space-y-3">
78
+ <p className="text-sm text-muted-foreground">
79
+ {t(
80
+ 'channel_discord.aiAutoReply.widget.description',
81
+ 'Let an AI agent answer inbound Discord messages. Every channel is off by default, and anything sensitive or low-confidence is proposed for a human to approve instead of being sent.',
82
+ )}
83
+ </p>
84
+ {entries.length === 0 ? (
85
+ <p className="text-sm text-muted-foreground">
86
+ {t(
87
+ 'channel_discord.aiAutoReply.widget.noChannels',
88
+ 'Connect a Discord bot first — auto-reply is configured per channel.',
89
+ )}
90
+ </p>
91
+ ) : (
92
+ <ul className="divide-y rounded-md border">
93
+ {entries.map((entry) => (
94
+ <li key={entry.channelId} className="flex flex-wrap items-center justify-between gap-3 p-3">
95
+ <div className="min-w-0">
96
+ <div className="truncate text-sm font-medium">{entry.displayName}</div>
97
+ <div className="truncate text-xs text-muted-foreground">
98
+ {entry.aiAgentId
99
+ ?? t('channel_discord.aiAutoReply.widget.noAgent', 'No agent selected')}
100
+ </div>
101
+ {entry.aiAutoReplyLastError ? (
102
+ <div className="truncate text-xs text-status-error-text">
103
+ {t('channel_discord.aiAutoReply.widget.lastError', 'Last attempt failed: {reason}', {
104
+ reason: entry.aiAutoReplyLastError,
105
+ })}
106
+ </div>
107
+ ) : null}
108
+ </div>
109
+ <div className="flex items-center gap-3">
110
+ {entry.aiAutoReplyEnabled ? (
111
+ // An armed channel whose last attempt failed is not "on" in any
112
+ // sense the operator cares about — say so on the tag, not only in
113
+ // the detail line they may not read.
114
+ <Tag variant={entry.aiAutoReplyLastError ? 'error' : 'success'} dot>
115
+ {entry.aiAutoReplyLastError
116
+ ? t('channel_discord.aiAutoReply.widget.failing', 'Auto-reply failing')
117
+ : t('channel_discord.aiAutoReply.widget.on', 'Auto-reply on')}
118
+ </Tag>
119
+ ) : (
120
+ <Tag variant="neutral">{t('channel_discord.aiAutoReply.widget.off', 'Auto-reply off')}</Tag>
121
+ )}
122
+ <Button asChild type="button" variant="outline">
123
+ <Link href={`/backend/channel_discord/channels/${encodeURIComponent(entry.channelId)}/ai-auto-reply`}>
124
+ {t('channel_discord.aiAutoReply.widget.configure', 'Configure')}
125
+ </Link>
126
+ </Button>
127
+ </div>
128
+ </li>
129
+ ))}
130
+ </ul>
131
+ )}
132
+ {truncated ? (
133
+ // A capped list that says nothing reads as "these are all your channels".
134
+ <p className="text-xs text-muted-foreground">
135
+ {t(
136
+ 'channel_discord.aiAutoReply.widget.truncated',
137
+ 'Only the most recent channels are shown. Open a channel from the Channels page to configure the rest.',
138
+ )}
139
+ </p>
140
+ ) : null}
141
+ </div>
142
+ )
143
+ }
@@ -0,0 +1,19 @@
1
+ import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
2
+ import { CHANNEL_DISCORD_VIEW_FEATURE } from '../../../lib/ai-features'
3
+ import DiscordAiAutoReplyWidget from './widget.client'
4
+
5
+ const widget: InjectionWidgetModule<Record<string, unknown>, Record<string, unknown>> = {
6
+ metadata: {
7
+ id: 'channel_discord.injection.ai-auto-reply',
8
+ title: 'AI auto-reply',
9
+ description: 'Per-channel AI auto-reply configuration for connected Discord bots.',
10
+ // Viewing the panel needs only read access; the settings page and its PUT
11
+ // route enforce `channel_discord.configure` before anything is written.
12
+ features: [CHANNEL_DISCORD_VIEW_FEATURE],
13
+ priority: 40,
14
+ enabled: true,
15
+ },
16
+ Widget: DiscordAiAutoReplyWidget,
17
+ }
18
+
19
+ export default widget