@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,124 @@
1
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
+
4
+ const logger = createLogger('channel_discord').child({ component: 'ai-agent-directory' })
5
+
6
+ /** The subset of an agent definition the settings surface needs to describe a choice. */
7
+ export type DiscordEligibleAgent = {
8
+ id: string
9
+ label: string
10
+ description: string
11
+ requiredFeatures: string[]
12
+ }
13
+
14
+ export type DiscordAgentDirectory =
15
+ | { available: false }
16
+ | { available: true; agents: DiscordEligibleAgent[] }
17
+
18
+ type AgentRegistryModule = {
19
+ loadAgentRegistry: () => Promise<unknown>
20
+ listAgents: () => Array<{
21
+ id: string
22
+ label?: string
23
+ description?: string
24
+ executionMode?: 'chat' | 'object'
25
+ output?: unknown
26
+ requiredFeatures?: string[]
27
+ }>
28
+ }
29
+
30
+ /**
31
+ * List the agents a Discord channel may legitimately be pointed at.
32
+ *
33
+ * "Eligible" means the AI runtime would accept the call the subscriber makes:
34
+ * `runAiAgentObject` requests execution mode `object`, and `checkAgentPolicy`
35
+ * rejects an agent that is neither declared object-mode nor carrying an output
36
+ * schema (`execution_mode_not_supported`). Offering a chat-mode agent in the
37
+ * picker would therefore be offering a setting that can only ever fail at
38
+ * runtime — the exact failure mode this feature was filed to remove.
39
+ *
40
+ * The import is dynamic and failure-tolerant because `@open-mercato/ai-assistant`
41
+ * is an optional peer: a deployment without it gets `{ available: false }`, and
42
+ * the settings surface then explains why auto-reply cannot be enabled instead of
43
+ * rendering an empty dropdown.
44
+ */
45
+ export async function listDiscordEligibleAgents(): Promise<DiscordAgentDirectory> {
46
+ let mod: AgentRegistryModule
47
+ try {
48
+ mod = (await import('@open-mercato/ai-assistant')) as unknown as AgentRegistryModule
49
+ } catch {
50
+ return { available: false }
51
+ }
52
+ if (typeof mod.loadAgentRegistry !== 'function' || typeof mod.listAgents !== 'function') {
53
+ return { available: false }
54
+ }
55
+
56
+ try {
57
+ await mod.loadAgentRegistry()
58
+ } catch (err) {
59
+ logger.warn('Failed to load the AI agent registry', { err })
60
+ return { available: false }
61
+ }
62
+
63
+ const agents = mod
64
+ .listAgents()
65
+ .filter((agent) => agent.executionMode === 'object' || Boolean(agent.output))
66
+ .map((agent) => ({
67
+ id: agent.id,
68
+ label: agent.label ?? agent.id,
69
+ description: agent.description ?? '',
70
+ requiredFeatures: agent.requiredFeatures ?? [],
71
+ }))
72
+
73
+ return { available: true, agents }
74
+ }
75
+
76
+ /**
77
+ * The eligible agent `agentId` names, or `null` when the AI peer is absent or the
78
+ * agent is not one auto-reply may be pointed at.
79
+ *
80
+ * This replaced a boolean `isDiscordEligibleAgentId`: every caller that wanted the
81
+ * answer also wanted the agent's `requiredFeatures` for the authorization check
82
+ * below, and asking twice meant loading the agent registry twice.
83
+ */
84
+ export async function findDiscordEligibleAgent(agentId: string): Promise<DiscordEligibleAgent | null> {
85
+ const directory = await listDiscordEligibleAgents()
86
+ if (!directory.available) return null
87
+ return directory.agents.find((agent) => agent.id === agentId) ?? null
88
+ }
89
+
90
+ /** The identity half of the invocability question — see {@link missingAgentFeatures}. */
91
+ export type AgentInvokingPrincipal = {
92
+ features: string[]
93
+ isSuperAdmin: boolean
94
+ }
95
+
96
+ /**
97
+ * The subset of an agent's `requiredFeatures` the principal does NOT hold.
98
+ *
99
+ * This is the second half of "can auto-reply actually invoke this agent", and it
100
+ * is the half the settings surface used to skip. `listDiscordEligibleAgents`
101
+ * answers the *shape* question (object-mode, so `runAiAgentObject` will take it);
102
+ * this answers the *authorization* question the runtime asks next, when
103
+ * `checkAgentPolicy` runs the agent's `requiredFeatures` against the principal
104
+ * from `lib/ai-service-principal.ts`. An agent that clears the first and fails the
105
+ * second stores a setting that can only fail later, inside a background
106
+ * subscriber — the failure mode issue #4778 was filed for.
107
+ *
108
+ * The check delegates to the platform's own `authorizeFeatures`, one feature at a
109
+ * time so the caller can name what is missing. Going through the shared helper —
110
+ * rather than a `Set.has` — is what makes wildcard grants (`customers.*`, `*`),
111
+ * removed features and disabled modules resolve exactly the way the runtime will
112
+ * resolve them; a hand-rolled comparison would reject a bot user whose role
113
+ * carries a wildcard and the operator would have no way to tell why.
114
+ */
115
+ export function missingAgentFeatures(
116
+ requiredFeatures: readonly string[] | undefined,
117
+ principal: AgentInvokingPrincipal,
118
+ ): string[] {
119
+ if (!requiredFeatures?.length) return []
120
+ return requiredFeatures.filter((featureId) => !authorizeFeatures([featureId], {
121
+ grantedFeatures: principal.features,
122
+ unrestricted: principal.isSuperAdmin,
123
+ }))
124
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ACL feature ids the AI auto-reply path depends on, in one dependency-free
3
+ * module so `acl.ts`, `ai-agents.ts`, the API routes, and the service principal
4
+ * can all import the same literal instead of repeating the string. Feature ids
5
+ * are FROZEN once shipped (`BACKWARD_COMPATIBILITY.md` § ACL features) — add new
6
+ * ones rather than renaming these.
7
+ */
8
+
9
+ /**
10
+ * Gates invoking the Discord auto-reply agent. It grants no data access of its
11
+ * own: the agent it unlocks runs in object mode with no tools, so the only thing
12
+ * this feature buys its holder is the ability to have text drafted.
13
+ *
14
+ * It is also the single grant the subscriber's service principal falls back to
15
+ * when a tenant has no channel-bot user — see `lib/ai-service-principal.ts`.
16
+ */
17
+ export const CHANNEL_DISCORD_AI_AUTO_REPLY_RUN_FEATURE = 'channel_discord.ai_auto_reply.run'
18
+
19
+ /** Gates reading and writing a channel's AI auto-reply configuration. */
20
+ export const CHANNEL_DISCORD_CONFIGURE_FEATURE = 'channel_discord.configure'
21
+
22
+ /** Gates read-only access to the Discord channel configuration surfaces. */
23
+ export const CHANNEL_DISCORD_VIEW_FEATURE = 'channel_discord.view'
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Ids shared between the AI proposal's message type, its commands, and the
3
+ * subscriber that files it. They live in their own dependency-free module so
4
+ * `message-types.ts` (loaded by the messages registry) and `commands/*.ts`
5
+ * (loaded by the command bus) can agree on the strings without importing each
6
+ * other and creating a cycle.
7
+ *
8
+ * Command ids are a contract surface (`BACKWARD_COMPATIBILITY.md`): renaming one
9
+ * breaks every proposal message already sitting in an operator's inbox, because
10
+ * the action row stored on that message still names the old id.
11
+ */
12
+ export const CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID =
13
+ 'channel_discord.ai_reply_proposal.approve'
14
+
15
+ export const CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID =
16
+ 'channel_discord.ai_reply_proposal.dismiss'
17
+
18
+ /**
19
+ * `sourceEntityType` on the proposal message. The approve command reads
20
+ * `sourceEntityId` as the id of the inbound Discord message being answered, so
21
+ * it can resolve the thread to reply into without trusting anything the operator
22
+ * sends from the browser.
23
+ */
24
+ export const CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE = 'messages.message'
@@ -0,0 +1,179 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CommandBus } from '@open-mercato/shared/lib/commands'
3
+ import { createLogger } from '@open-mercato/shared/lib/logger'
4
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import {
6
+ ChannelThreadMapping,
7
+ CommunicationChannel,
8
+ ExternalConversation,
9
+ } from '@open-mercato/core/modules/communication_channels/data/entities'
10
+ import type { Message } from '@open-mercato/core/modules/messages/data/entities'
11
+ import { CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE } from './ai-proposal-contract'
12
+ import { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from '../message-types'
13
+
14
+ const logger = createLogger('channel_discord').child({ component: 'ai-proposal' })
15
+
16
+ export type DiscordProposalScope = { tenantId: string; organizationId: string | null }
17
+
18
+ export type FileDiscordProposalResult = {
19
+ proposalMessageId: string
20
+ /** `'assigned'` went to a human with approve/dismiss actions; `'stored'` is a draft nobody was notified about. */
21
+ outcome: 'assigned' | 'stored'
22
+ reviewerUserId: string | null
23
+ }
24
+
25
+ const MAX_SUBJECT_LENGTH = 200
26
+ const MAX_SUMMARY_LENGTH = 500
27
+
28
+ /**
29
+ * Find the human who should review a proposal for this conversation.
30
+ *
31
+ * Order mirrors what the hub itself does when it decides who an INBOUND channel
32
+ * message is addressed to (`ingest-inbound-message.ts` uses the thread mapping's
33
+ * assignee), then widens to the two other owners a Discord channel can have. It
34
+ * deliberately does not fan out to "everyone with the feature": a proposal is a
35
+ * task for the person who owns the conversation, not a broadcast.
36
+ */
37
+ export async function resolveDiscordProposalReviewer(params: {
38
+ em: EntityManager
39
+ scope: DiscordProposalScope
40
+ channelId: string
41
+ messageThreadId: string | null
42
+ externalConversationId: string | null
43
+ }): Promise<string | null> {
44
+ const { em, scope, channelId, messageThreadId, externalConversationId } = params
45
+
46
+ if (messageThreadId) {
47
+ const mapping = await em.findOne(ChannelThreadMapping, {
48
+ messageThreadId,
49
+ tenantId: scope.tenantId,
50
+ })
51
+ if (mapping?.assignedUserId) return mapping.assignedUserId
52
+ }
53
+
54
+ if (externalConversationId) {
55
+ const conversation = await em.findOne(ExternalConversation, {
56
+ id: externalConversationId,
57
+ tenantId: scope.tenantId,
58
+ })
59
+ if (conversation?.assignedUserId) return conversation.assignedUserId
60
+ }
61
+
62
+ const channel = await findOneWithDecryption(
63
+ em,
64
+ CommunicationChannel,
65
+ { id: channelId, tenantId: scope.tenantId, deletedAt: null },
66
+ undefined,
67
+ { tenantId: scope.tenantId, organizationId: scope.organizationId ?? scope.tenantId },
68
+ )
69
+ return channel?.userId ?? null
70
+ }
71
+
72
+ /**
73
+ * File the `complex`-tier proposal (SPEC 2026-06-19 § AI bot wiring step 5,
74
+ * issue #4778). This is what replaced "log a line and return".
75
+ *
76
+ * Two shapes, one helper:
77
+ *
78
+ * - A reviewer resolves → an INTERNAL message addressed to them, carrying the
79
+ * drafted reply as its body and the approve/dismiss actions declared by
80
+ * `message-types.ts`. Approving dispatches
81
+ * `channel_discord.ai_reply_proposal.approve`, which composes the public reply
82
+ * through the same hub path the easy tier uses.
83
+ * - No reviewer resolves (an unassigned tenant-scoped channel — the same case
84
+ * where the hub files the inbound message with no recipient either) → the
85
+ * proposal is stored as a DRAFT so the text is not lost, and the fact that
86
+ * nobody was notified is logged. Assigning the conversation is the operator
87
+ * action that turns future proposals into approval cards.
88
+ *
89
+ * Neither shape can reach Discord on its own: a draft emits no
90
+ * `messages.message.sent`, and the internal message is `visibility: 'internal'`,
91
+ * which the hub's outbound bridge does not deliver. Only the approve command
92
+ * sends, and only a human can call it.
93
+ */
94
+ export async function fileDiscordReplyProposal(params: {
95
+ em: EntityManager
96
+ commandBus: CommandBus
97
+ containerCtx: unknown
98
+ scope: DiscordProposalScope
99
+ channelId: string
100
+ inboundMessage: Pick<Message, 'id' | 'type' | 'subject' | 'threadId'>
101
+ externalConversationId: string | null
102
+ botUserId: string
103
+ proposedReply: string
104
+ summary: string
105
+ reason: string
106
+ }): Promise<FileDiscordProposalResult> {
107
+ const {
108
+ em,
109
+ commandBus,
110
+ containerCtx,
111
+ scope,
112
+ channelId,
113
+ inboundMessage,
114
+ externalConversationId,
115
+ botUserId,
116
+ proposedReply,
117
+ summary,
118
+ reason,
119
+ } = params
120
+
121
+ const reviewerUserId = await resolveDiscordProposalReviewer({
122
+ em,
123
+ scope,
124
+ channelId,
125
+ messageThreadId: inboundMessage.threadId ?? inboundMessage.id,
126
+ externalConversationId,
127
+ })
128
+
129
+ const subject = summary.trim().slice(0, MAX_SUBJECT_LENGTH) || 'Proposed Discord reply'
130
+
131
+ const composeInput = {
132
+ type: CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE,
133
+ visibility: 'internal' as const,
134
+ sourceEntityType: CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE,
135
+ sourceEntityId: inboundMessage.id,
136
+ recipients: reviewerUserId ? [{ userId: reviewerUserId, type: 'to' as const }] : [],
137
+ subject,
138
+ body: proposedReply,
139
+ bodyFormat: 'markdown' as const,
140
+ priority: 'normal' as const,
141
+ sendViaEmail: false,
142
+ // A draft when there is nobody to address it to — the compose validator only
143
+ // requires a recipient for a message that is actually being delivered.
144
+ isDraft: !reviewerUserId,
145
+ tenantId: scope.tenantId,
146
+ organizationId: scope.organizationId,
147
+ userId: botUserId,
148
+ }
149
+
150
+ const composeResult = await commandBus.execute<typeof composeInput, { id: string }>(
151
+ 'messages.messages.compose',
152
+ {
153
+ input: composeInput,
154
+ ctx: containerCtx as never,
155
+ },
156
+ )
157
+
158
+ const outcome: FileDiscordProposalResult['outcome'] = reviewerUserId ? 'assigned' : 'stored'
159
+ if (outcome === 'stored') {
160
+ logger.info(
161
+ 'Filed a Discord AI reply proposal with no reviewer — assign the conversation to get approval cards',
162
+ { channelId, reason, proposalMessageId: composeResult.result.id },
163
+ )
164
+ } else {
165
+ logger.info('Filed a Discord AI reply proposal for review', {
166
+ channelId,
167
+ reason,
168
+ proposalMessageId: composeResult.result.id,
169
+ })
170
+ }
171
+
172
+ return {
173
+ proposalMessageId: composeResult.result.id,
174
+ outcome,
175
+ reviewerUserId,
176
+ }
177
+ }
178
+
179
+ export { MAX_SUMMARY_LENGTH }
@@ -0,0 +1,179 @@
1
+ import { discordChannelStateSchema, type DiscordChannelState } from './credentials'
2
+
3
+ /**
4
+ * AI auto-reply helpers (SPEC 2026-06-19 § AI bot wiring).
5
+ *
6
+ * `aiAutoReplyEnabled` / `aiAgentId` are written by the channel's AI auto-reply
7
+ * settings form (`backend/channel_discord/channels/[id]/ai-auto-reply`) through
8
+ * `PUT /api/channel_discord/channels/{id}/ai-auto-reply`. Both default to off, so
9
+ * a channel nobody configured never reaches the model.
10
+ *
11
+ * `ai_assistant` is an OPTIONAL peer. This module never statically imports it;
12
+ * the subscriber resolves it softly via DI (`mcpToolRegistry`) and, only when it
13
+ * is present AND per-channel auto-reply is enabled, dynamically imports
14
+ * `runAiAgentObject`. When the peer is absent the subscriber no-ops and the channel
15
+ * still works as a plain inbox (module-decoupling contract).
16
+ */
17
+
18
+ export type ReplyTier = 'easy' | 'complex'
19
+
20
+ export interface ClassificationResult {
21
+ tier: ReplyTier
22
+ confidence: number
23
+ reason: string
24
+ }
25
+
26
+ // Signals that a message needs a human (mutations, escalation, sensitive topics).
27
+ // The bot never auto-answers these — it proposes for approval instead (SPEC-056
28
+ // easy-vs-complex tiering).
29
+ const COMPLEX_SIGNALS = [
30
+ /\brefund\b/i,
31
+ /\bcancel\b/i,
32
+ /\binvoice\b/i,
33
+ /\bchargeback\b/i,
34
+ /\bcomplain/i,
35
+ /\bescalat/i,
36
+ /\blegal\b/i,
37
+ /\bhuman\b/i,
38
+ /\bagent\b/i,
39
+ /\bmanager\b/i,
40
+ /\border\b/i,
41
+ /\bpayment\b/i,
42
+ /\bprice\b/i,
43
+ /\bdiscount\b/i,
44
+ ]
45
+
46
+ // Signals that a message is trying to steer the model rather than ask a
47
+ // question (prompt injection). Always propose-only — a human sees the attempt
48
+ // before anything is sent.
49
+ const INJECTION_SIGNALS = [
50
+ /\bignore\b[\s\S]{0,60}\b(instructions?|prompts?|rules?)\b/i,
51
+ /\bdisregard\b/i,
52
+ /\bsystem\s*prompt\b/i,
53
+ /\bjailbreak/i,
54
+ /\bdeveloper\s+mode\b/i,
55
+ /\byou\s+are\s+now\b/i,
56
+ /\bnew\s+instructions?\b/i,
57
+ /\bpretend\s+(to\s+be|you)\b/i,
58
+ /\bact\s+as\b/i,
59
+ /\brole\s*-?\s*play\b/i,
60
+ /^\s*(system|assistant)\s*:/im,
61
+ ]
62
+
63
+ // The bot must never act on content it would have to fetch — a link is a
64
+ // second, unvetted instruction channel.
65
+ const LINK_SIGNAL = /\bhttps?:\/\/|\bwww\.|\bdiscord\.gg\//i
66
+
67
+ // Zero-width, bidi-override, and word-joiner characters have no place in a
68
+ // legitimate support question; their presence means someone is hiding content
69
+ // from pattern matching (e.g. `re​fund`).
70
+ const INVISIBLE_CHARS = /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]/
71
+
72
+ /**
73
+ * Build the copy of the text that signals are matched against. Defeats the
74
+ * cheap obfuscations of a regex gate: compatibility forms (`refund` →
75
+ * `refund`, NFKC), invisible characters, markdown emphasis/spoilers splitting a
76
+ * keyword (`**ref**und`, `||refund||`), and combining-mark zalgo (NFKD + strip).
77
+ */
78
+ function normalizeForClassification(text: string): string {
79
+ return text
80
+ .normalize('NFKC')
81
+ .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]/g, '')
82
+ .replace(/[`*_~|]/g, '')
83
+ .normalize('NFKD')
84
+ .replace(/[\u0300-\u036f]/g, '')
85
+ .replace(/[^\S\n]+/g, ' ')
86
+ .trim()
87
+ }
88
+
89
+ function matchSignal(signals: RegExp[], raw: string, normalized: string): RegExp | null {
90
+ for (const signal of signals) {
91
+ if (signal.test(raw) || signal.test(normalized)) return signal
92
+ }
93
+ return null
94
+ }
95
+
96
+ /**
97
+ * Classify an inbound Discord message as "easy" (bot may answer directly) vs
98
+ * "complex" (propose-only, human approves). A deliberately conservative
99
+ * heuristic — when in doubt it returns `complex` so nothing risky auto-sends.
100
+ *
101
+ * Hardening note: signals are matched against a normalized copy as well as the
102
+ * raw text, and injection/link/obfuscation attempts force `complex`. This is
103
+ * defense-in-depth layering, not a proof — the real containment for anything
104
+ * that slips through is downstream: propose-only for complex, an object-mode
105
+ * agent the runtime never gives a tool to, a service principal holding one
106
+ * non-data feature and never `isSuperAdmin`, `allowed_mentions: { parse: [] }`,
107
+ * and the audited outbound path.
108
+ */
109
+ export function classifyDiscordMessage(body: string): ClassificationResult {
110
+ const text = (body ?? '').trim()
111
+ if (text.length === 0) {
112
+ return { tier: 'complex', confidence: 0, reason: 'empty-body' }
113
+ }
114
+ if (INVISIBLE_CHARS.test(text)) {
115
+ return { tier: 'complex', confidence: 0.95, reason: 'obfuscation-suspect' }
116
+ }
117
+ const normalized = normalizeForClassification(text)
118
+ const injection = matchSignal(INJECTION_SIGNALS, text, normalized)
119
+ if (injection) {
120
+ return { tier: 'complex', confidence: 0.95, reason: `injection-suspect:${injection.source}` }
121
+ }
122
+ if (LINK_SIGNAL.test(text) || LINK_SIGNAL.test(normalized)) {
123
+ return { tier: 'complex', confidence: 0.9, reason: 'contains-link' }
124
+ }
125
+ const complexSignal = matchSignal(COMPLEX_SIGNALS, text, normalized)
126
+ if (complexSignal) {
127
+ return { tier: 'complex', confidence: 0.9, reason: `matched:${complexSignal.source}` }
128
+ }
129
+ // Long messages or multi-question threads are more likely to need nuance.
130
+ const questionCount = (text.match(/\?/g) ?? []).length
131
+ if (text.length > 600 || questionCount > 2) {
132
+ return { tier: 'complex', confidence: 0.6, reason: 'long-or-multi-question' }
133
+ }
134
+ return { tier: 'easy', confidence: 0.8, reason: 'short-simple' }
135
+ }
136
+
137
+ /** Per-channel AI auto-reply is OFF unless explicitly enabled on channel state. */
138
+ export function isAiAutoReplyEnabled(channelState: unknown): boolean {
139
+ const parsed = discordChannelStateSchema.safeParse(channelState ?? {})
140
+ return parsed.success ? Boolean(parsed.data.aiAutoReplyEnabled) : false
141
+ }
142
+
143
+ export function resolveAiAgentId(channelState: unknown): string | undefined {
144
+ const parsed = discordChannelStateSchema.safeParse(channelState ?? {})
145
+ return parsed.success ? parsed.data.aiAgentId : undefined
146
+ }
147
+
148
+ /** Whether a previous auto-reply attempt left a failure marker still to be cleared. */
149
+ export function hasStoredAutoReplyFailure(channelState: unknown): boolean {
150
+ const parsed = discordChannelStateSchema.safeParse(channelState ?? {})
151
+ return parsed.success ? parsed.data.aiAutoReplyLastError !== undefined : false
152
+ }
153
+
154
+ export interface SubscriberResolver {
155
+ resolve: <T = unknown>(name: string) => T
156
+ container?: { resolve: <T = unknown>(name: string) => T }
157
+ }
158
+
159
+ /**
160
+ * Soft presence check for the `ai_assistant` module. The module registers
161
+ * `mcpToolRegistry` in DI, so a successful resolve means the peer is active.
162
+ * Returns `false` (no-op) when it is absent — never throws.
163
+ */
164
+ export function isAiAssistantAvailable(ctx: SubscriberResolver): boolean {
165
+ const resolver =
166
+ typeof ctx?.resolve === 'function'
167
+ ? ctx.resolve
168
+ : ctx?.container?.resolve
169
+ ? ctx.container.resolve.bind(ctx.container)
170
+ : null
171
+ if (!resolver) return false
172
+ try {
173
+ return Boolean(resolver('mcpToolRegistry'))
174
+ } catch {
175
+ return false
176
+ }
177
+ }
178
+
179
+ export type { DiscordChannelState }
@@ -0,0 +1,143 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
+ import {
4
+ COMMUNICATION_CHANNELS_SYSTEM_USER_ID,
5
+ resolveCommunicationChannelsSystemUserId,
6
+ } from '@open-mercato/core/modules/communication_channels/lib/system-user'
7
+ import { CHANNEL_DISCORD_AI_AUTO_REPLY_RUN_FEATURE } from './ai-features'
8
+
9
+ const logger = createLogger('channel_discord').child({ component: 'ai-service-principal' })
10
+
11
+ /**
12
+ * The auth context the AI auto-reply subscriber runs an agent under.
13
+ *
14
+ * Field names match `AiChatRequestContext` from `@open-mercato/ai-assistant` on
15
+ * purpose — the subscriber hands this object straight to `runAiAgentObject` —
16
+ * but the type is declared locally so the optional peer stays optional.
17
+ */
18
+ export type DiscordAiPrincipal = {
19
+ tenantId: string
20
+ organizationId: string | null
21
+ userId: string
22
+ features: string[]
23
+ isSuperAdmin: boolean
24
+ /** Which of the two identities below produced the grants. Logged, never sent to the model. */
25
+ source: 'channel_bot_user' | 'provider_service_principal'
26
+ }
27
+
28
+ /**
29
+ * The grants the provider's own service principal carries by construction, with
30
+ * no operator setup. Exactly one non-data feature: it unlocks the provider's own
31
+ * object-mode auto-reply agent and nothing else. Any agent gated on anything
32
+ * broader — a customers support agent, say — is NOT reachable from this identity
33
+ * and needs the channel-bot user path below.
34
+ */
35
+ const PROVIDER_SERVICE_PRINCIPAL_FEATURES: readonly string[] = [
36
+ CHANNEL_DISCORD_AI_AUTO_REPLY_RUN_FEATURE,
37
+ ]
38
+
39
+ type AclSnapshot = { features: string[]; isSuperAdmin: boolean }
40
+
41
+ type RbacServiceLike = {
42
+ loadAcl: (
43
+ userId: string,
44
+ scope: { tenantId: string | null; organizationId: string | null },
45
+ ) => Promise<AclSnapshot & { organizations: string[] | null }>
46
+ }
47
+
48
+ export type DiscordAiPrincipalResolver = {
49
+ resolve: <T = unknown>(name: string) => T
50
+ }
51
+
52
+ function tryResolveRbacService(resolver: DiscordAiPrincipalResolver): RbacServiceLike | null {
53
+ try {
54
+ const service = resolver.resolve<RbacServiceLike>('rbacService')
55
+ return service && typeof service.loadAcl === 'function' ? service : null
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Resolve the identity an inbound Discord message may borrow to have a reply
63
+ * drafted (SPEC 2026-06-19 § AI bot wiring — "Safety / RBAC", issue #4778).
64
+ *
65
+ * There are two identities, and the difference between them is the whole point:
66
+ *
67
+ * 1. **Channel-bot user** — a real `auth.user` row following the hub's
68
+ * `system+communication_channels@<tenantId>.local` convention. When a tenant
69
+ * creates one, its role grants are loaded through `rbacService.loadAcl` and
70
+ * become the principal's features. This is the supported way to let auto-reply
71
+ * use an agent this provider does not own: grant the bot user the role that
72
+ * agent's `requiredFeatures` need.
73
+ * 2. **Provider service principal** — the fallback when no such user exists.
74
+ * It carries {@link PROVIDER_SERVICE_PRINCIPAL_FEATURES} only.
75
+ *
76
+ * Two invariants hold on both paths:
77
+ *
78
+ * - `isSuperAdmin` is **always** false. An inbound message from a public Discord
79
+ * server must not be able to borrow a super-admin's ACL, so a bot user who
80
+ * happens to be one is clamped down (and the clamp is logged, because it means
81
+ * the tenant gave a bot account far more than it needs).
82
+ * - The provider grant is always present. It is what makes the provider's own
83
+ * agent work out of the box; it can never widen access to anything else,
84
+ * because no tool or route is gated on it.
85
+ */
86
+ export async function resolveDiscordAiPrincipal(params: {
87
+ em: EntityManager
88
+ resolver: DiscordAiPrincipalResolver
89
+ scope: { tenantId: string; organizationId: string | null }
90
+ }): Promise<DiscordAiPrincipal> {
91
+ const { em, resolver, scope } = params
92
+ const base: DiscordAiPrincipal = {
93
+ tenantId: scope.tenantId,
94
+ organizationId: scope.organizationId,
95
+ userId: COMMUNICATION_CHANNELS_SYSTEM_USER_ID,
96
+ features: [...PROVIDER_SERVICE_PRINCIPAL_FEATURES],
97
+ isSuperAdmin: false,
98
+ source: 'provider_service_principal',
99
+ }
100
+
101
+ const botUserId = await resolveCommunicationChannelsSystemUserId(em, scope.tenantId, null)
102
+ if (botUserId === COMMUNICATION_CHANNELS_SYSTEM_USER_ID) return base
103
+
104
+ const rbacService = tryResolveRbacService(resolver)
105
+ if (!rbacService) {
106
+ logger.debug('rbacService unavailable — running auto-reply under the provider service principal', {
107
+ tenantId: scope.tenantId,
108
+ })
109
+ return { ...base, userId: botUserId }
110
+ }
111
+
112
+ let acl: AclSnapshot
113
+ try {
114
+ acl = await rbacService.loadAcl(botUserId, {
115
+ tenantId: scope.tenantId,
116
+ organizationId: scope.organizationId,
117
+ })
118
+ } catch (err) {
119
+ logger.warn('Failed to load the channel-bot ACL — falling back to the provider service principal', {
120
+ tenantId: scope.tenantId,
121
+ err,
122
+ })
123
+ return { ...base, userId: botUserId }
124
+ }
125
+
126
+ if (acl.isSuperAdmin) {
127
+ logger.warn(
128
+ 'Channel-bot user is a super-admin; clamping the auto-reply principal to its explicit features',
129
+ { tenantId: scope.tenantId },
130
+ )
131
+ }
132
+
133
+ const features = Array.from(new Set([...PROVIDER_SERVICE_PRINCIPAL_FEATURES, ...acl.features]))
134
+ return {
135
+ ...base,
136
+ userId: botUserId,
137
+ features,
138
+ isSuperAdmin: false,
139
+ source: 'channel_bot_user',
140
+ }
141
+ }
142
+
143
+ export { PROVIDER_SERVICE_PRINCIPAL_FEATURES }