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