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