@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.
- package/.turbo/turbo-build.log +2 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js +86 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js +39 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js +97 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js +65 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js +110 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js +103 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js +93 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js +52 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js +46 -0
- package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js +16 -0
- package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/meta.js +10 -0
- package/dist/modules/channel_discord/__integration__/meta.js.map +7 -0
- package/dist/modules/channel_discord/acl.js +15 -0
- package/dist/modules/channel_discord/acl.js.map +7 -0
- package/dist/modules/channel_discord/ai-agents.js +97 -0
- package/dist/modules/channel_discord/ai-agents.js.map +7 -0
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js +73 -0
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js.map +7 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js +222 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js.map +7 -0
- package/dist/modules/channel_discord/api/interactions/route.js +173 -0
- package/dist/modules/channel_discord/api/interactions/route.js.map +7 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js +196 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js.map +7 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js +14 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js.map +7 -0
- package/dist/modules/channel_discord/cli.js +142 -0
- package/dist/modules/channel_discord/cli.js.map +7 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js +116 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js.map +7 -0
- package/dist/modules/channel_discord/commands/update-ai-auto-reply.js +104 -0
- package/dist/modules/channel_discord/commands/update-ai-auto-reply.js.map +7 -0
- package/dist/modules/channel_discord/di.js +23 -0
- package/dist/modules/channel_discord/di.js.map +7 -0
- package/dist/modules/channel_discord/index.js +9 -0
- package/dist/modules/channel_discord/index.js.map +7 -0
- package/dist/modules/channel_discord/integration.js +96 -0
- package/dist/modules/channel_discord/integration.js.map +7 -0
- package/dist/modules/channel_discord/lib/adapter.js +248 -0
- package/dist/modules/channel_discord/lib/adapter.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-agent-directory.js +45 -0
- package/dist/modules/channel_discord/lib/ai-agent-directory.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-features.js +9 -0
- package/dist/modules/channel_discord/lib/ai-features.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-proposal-contract.js +9 -0
- package/dist/modules/channel_discord/lib/ai-proposal-contract.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-proposal.js +109 -0
- package/dist/modules/channel_discord/lib/ai-proposal.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-reply.js +96 -0
- package/dist/modules/channel_discord/lib/ai-reply.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-service-principal.js +70 -0
- package/dist/modules/channel_discord/lib/ai-service-principal.js.map +7 -0
- package/dist/modules/channel_discord/lib/capabilities.js +42 -0
- package/dist/modules/channel_discord/lib/capabilities.js.map +7 -0
- package/dist/modules/channel_discord/lib/channel-access.js +62 -0
- package/dist/modules/channel_discord/lib/channel-access.js.map +7 -0
- package/dist/modules/channel_discord/lib/channel-state-store.js +103 -0
- package/dist/modules/channel_discord/lib/channel-state-store.js.map +7 -0
- package/dist/modules/channel_discord/lib/convert-outbound.js +38 -0
- package/dist/modules/channel_discord/lib/convert-outbound.js.map +7 -0
- package/dist/modules/channel_discord/lib/credentials.js +61 -0
- package/dist/modules/channel_discord/lib/credentials.js.map +7 -0
- package/dist/modules/channel_discord/lib/discord-gateway-client.js +289 -0
- package/dist/modules/channel_discord/lib/discord-gateway-client.js.map +7 -0
- package/dist/modules/channel_discord/lib/discord-rest.js +203 -0
- package/dist/modules/channel_discord/lib/discord-rest.js.map +7 -0
- package/dist/modules/channel_discord/lib/failure-reason.js +30 -0
- package/dist/modules/channel_discord/lib/failure-reason.js.map +7 -0
- package/dist/modules/channel_discord/lib/gateway-bridge.js +42 -0
- package/dist/modules/channel_discord/lib/gateway-bridge.js.map +7 -0
- package/dist/modules/channel_discord/lib/health.js +37 -0
- package/dist/modules/channel_discord/lib/health.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-dispatch.js +164 -0
- package/dist/modules/channel_discord/lib/interactions-dispatch.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-handler.js +115 -0
- package/dist/modules/channel_discord/lib/interactions-handler.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-queue.js +17 -0
- package/dist/modules/channel_discord/lib/interactions-queue.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-verify.js +84 -0
- package/dist/modules/channel_discord/lib/interactions-verify.js.map +7 -0
- package/dist/modules/channel_discord/lib/normalize-inbound.js +59 -0
- package/dist/modules/channel_discord/lib/normalize-inbound.js.map +7 -0
- package/dist/modules/channel_discord/lib/preset.js +63 -0
- package/dist/modules/channel_discord/lib/preset.js.map +7 -0
- package/dist/modules/channel_discord/lib/queue-strategy.js +12 -0
- package/dist/modules/channel_discord/lib/queue-strategy.js.map +7 -0
- package/dist/modules/channel_discord/lib/slash-commands.js +42 -0
- package/dist/modules/channel_discord/lib/slash-commands.js.map +7 -0
- package/dist/modules/channel_discord/message-types.js +64 -0
- package/dist/modules/channel_discord/message-types.js.map +7 -0
- package/dist/modules/channel_discord/setup.js +62 -0
- package/dist/modules/channel_discord/setup.js.map +7 -0
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js +185 -0
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js +79 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js +20 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js +220 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection-table.js +26 -0
- package/dist/modules/channel_discord/widgets/injection-table.js.map +7 -0
- package/dist/modules/channel_discord/workers/discord-gateway.js +270 -0
- package/dist/modules/channel_discord/workers/discord-gateway.js.map +7 -0
- package/dist/modules/channel_discord/workers/discord-interactions.js +113 -0
- package/dist/modules/channel_discord/workers/discord-interactions.js.map +7 -0
- package/jest.config.cjs +38 -0
- package/package.json +104 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.ts +109 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.ts +58 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.ts +175 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.ts +83 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.ts +156 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.ts +144 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.ts +119 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.ts +75 -0
- package/src/modules/channel_discord/__integration__/helpers/discordSignature.ts +77 -0
- package/src/modules/channel_discord/__integration__/helpers/eventsQueue.ts +26 -0
- package/src/modules/channel_discord/__integration__/meta.ts +6 -0
- package/src/modules/channel_discord/__tests__/ai-auto-reply.policy.integration.test.ts +286 -0
- package/src/modules/channel_discord/acl.ts +19 -0
- package/src/modules/channel_discord/ai-agents.ts +157 -0
- package/src/modules/channel_discord/api/ai-auto-reply/channels/__tests__/route.test.ts +175 -0
- package/src/modules/channel_discord/api/ai-auto-reply/channels/route.ts +99 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/__tests__/route.test.ts +311 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.ts +299 -0
- package/src/modules/channel_discord/api/interactions/route.ts +242 -0
- package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.ts +18 -0
- package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.tsx +288 -0
- package/src/modules/channel_discord/cli.ts +234 -0
- package/src/modules/channel_discord/commands/__tests__/ai-reply-proposal.test.ts +155 -0
- package/src/modules/channel_discord/commands/__tests__/update-ai-auto-reply.test.ts +185 -0
- package/src/modules/channel_discord/commands/ai-reply-proposal.ts +162 -0
- package/src/modules/channel_discord/commands/update-ai-auto-reply.ts +157 -0
- package/src/modules/channel_discord/di.ts +26 -0
- package/src/modules/channel_discord/i18n/de.json +57 -0
- package/src/modules/channel_discord/i18n/en.json +57 -0
- package/src/modules/channel_discord/i18n/es.json +57 -0
- package/src/modules/channel_discord/i18n/ko.json +57 -0
- package/src/modules/channel_discord/i18n/pl.json +57 -0
- package/src/modules/channel_discord/index.ts +6 -0
- package/src/modules/channel_discord/integration.ts +98 -0
- package/src/modules/channel_discord/lib/__tests__/adapter.test.ts +206 -0
- package/src/modules/channel_discord/lib/__tests__/ai-agent-directory.test.ts +61 -0
- package/src/modules/channel_discord/lib/__tests__/ai-reply.test.ts +115 -0
- package/src/modules/channel_discord/lib/__tests__/capabilities.test.ts +170 -0
- package/src/modules/channel_discord/lib/__tests__/channel-quarantine.test.ts +89 -0
- package/src/modules/channel_discord/lib/__tests__/channel-state-store.auto-reply-marker.test.ts +145 -0
- package/src/modules/channel_discord/lib/__tests__/channel-state-store.test.ts +78 -0
- package/src/modules/channel_discord/lib/__tests__/convert-outbound.test.ts +39 -0
- package/src/modules/channel_discord/lib/__tests__/discord-gateway-client.test.ts +243 -0
- package/src/modules/channel_discord/lib/__tests__/failure-reason.test.ts +68 -0
- package/src/modules/channel_discord/lib/__tests__/gateway-bridge.test.ts +62 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-dispatch.test.ts +223 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-handler.test.ts +469 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-verify.test.ts +126 -0
- package/src/modules/channel_discord/lib/__tests__/normalize-inbound.identity.test.ts +64 -0
- package/src/modules/channel_discord/lib/__tests__/normalize-inbound.test.ts +67 -0
- package/src/modules/channel_discord/lib/__tests__/preset.test.ts +110 -0
- package/src/modules/channel_discord/lib/__tests__/queue-strategy.test.ts +33 -0
- package/src/modules/channel_discord/lib/adapter.ts +359 -0
- package/src/modules/channel_discord/lib/ai-agent-directory.ts +124 -0
- package/src/modules/channel_discord/lib/ai-features.ts +23 -0
- package/src/modules/channel_discord/lib/ai-proposal-contract.ts +24 -0
- package/src/modules/channel_discord/lib/ai-proposal.ts +179 -0
- package/src/modules/channel_discord/lib/ai-reply.ts +179 -0
- package/src/modules/channel_discord/lib/ai-service-principal.ts +143 -0
- package/src/modules/channel_discord/lib/capabilities.ts +104 -0
- package/src/modules/channel_discord/lib/channel-access.ts +110 -0
- package/src/modules/channel_discord/lib/channel-state-store.ts +217 -0
- package/src/modules/channel_discord/lib/convert-outbound.ts +88 -0
- package/src/modules/channel_discord/lib/credentials.ts +88 -0
- package/src/modules/channel_discord/lib/discord-gateway-client.ts +432 -0
- package/src/modules/channel_discord/lib/discord-rest.ts +393 -0
- package/src/modules/channel_discord/lib/failure-reason.ts +49 -0
- package/src/modules/channel_discord/lib/gateway-bridge.ts +79 -0
- package/src/modules/channel_discord/lib/health.ts +55 -0
- package/src/modules/channel_discord/lib/interactions-dispatch.ts +288 -0
- package/src/modules/channel_discord/lib/interactions-handler.ts +267 -0
- package/src/modules/channel_discord/lib/interactions-queue.ts +52 -0
- package/src/modules/channel_discord/lib/interactions-verify.ts +174 -0
- package/src/modules/channel_discord/lib/normalize-inbound.ts +89 -0
- package/src/modules/channel_discord/lib/preset.ts +109 -0
- package/src/modules/channel_discord/lib/queue-strategy.ts +29 -0
- package/src/modules/channel_discord/lib/slash-commands.ts +57 -0
- package/src/modules/channel_discord/message-types.ts +75 -0
- package/src/modules/channel_discord/setup.ts +78 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.armed.test.ts +149 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.failure-marker.test.ts +133 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.test.ts +273 -0
- package/src/modules/channel_discord/subscribers/ai-auto-reply.ts +325 -0
- package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.tsx +143 -0
- package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.ts +19 -0
- package/src/modules/channel_discord/widgets/injection/connect/widget.client.tsx +237 -0
- package/src/modules/channel_discord/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_discord/widgets/injection-table.ts +24 -0
- package/src/modules/channel_discord/workers/__tests__/discord-gateway.single-identify.test.ts +88 -0
- package/src/modules/channel_discord/workers/__tests__/discord-gateway.test.ts +179 -0
- package/src/modules/channel_discord/workers/__tests__/discord-interactions.test.ts +152 -0
- package/src/modules/channel_discord/workers/discord-gateway.ts +458 -0
- package/src/modules/channel_discord/workers/discord-interactions.ts +162 -0
- package/tsconfig.json +9 -0
- 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. `refund`).
|
|
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 }
|