@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,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write half of the configuration path (issue #4778): the only place the
|
|
3
|
+
* `aiAutoReplyEnabled` / `aiAgentId` keys are ever set.
|
|
4
|
+
*
|
|
5
|
+
* What matters here is not that the values land — it is that they land WITHOUT
|
|
6
|
+
* collateral damage. `channelState` is shared with the gateway worker, which
|
|
7
|
+
* writes resume state from a socket callback, so a settings save that replaced
|
|
8
|
+
* the blob would silently cost a live session its cursor.
|
|
9
|
+
*/
|
|
10
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
11
|
+
findOneWithDecryption: jest.fn(),
|
|
12
|
+
}))
|
|
13
|
+
jest.mock('@open-mercato/shared/lib/commands/flush', () => ({
|
|
14
|
+
withAtomicFlush: jest.fn(async (_em: unknown, phases: Array<() => unknown>) => {
|
|
15
|
+
for (const phase of phases) await phase()
|
|
16
|
+
}),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
import updateAiAutoReplyCommand from '../update-ai-auto-reply'
|
|
20
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
21
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
22
|
+
import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
23
|
+
|
|
24
|
+
const findOne = findOneWithDecryption as unknown as jest.Mock
|
|
25
|
+
|
|
26
|
+
const TENANT = '11111111-1111-4111-8111-111111111111'
|
|
27
|
+
const ORG = '22222222-2222-4222-8222-222222222222'
|
|
28
|
+
const CHANNEL = '33333333-3333-4333-8333-333333333333'
|
|
29
|
+
const UPDATED_AT = new Date('2026-08-01T10:00:00.000Z')
|
|
30
|
+
|
|
31
|
+
function makeCtx(request?: Request) {
|
|
32
|
+
const em = { fork: () => ({}) }
|
|
33
|
+
return {
|
|
34
|
+
container: { resolve: (name: string) => (name === 'em' ? em : {}) },
|
|
35
|
+
auth: null,
|
|
36
|
+
organizationScope: null,
|
|
37
|
+
selectedOrganizationId: ORG,
|
|
38
|
+
organizationIds: [ORG],
|
|
39
|
+
request,
|
|
40
|
+
} as never
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function channelRow(channelState: Record<string, unknown>) {
|
|
44
|
+
return { id: CHANNEL, updatedAt: UPDATED_AT, channelState }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const gatewayState = {
|
|
48
|
+
sessionId: 'sess-1',
|
|
49
|
+
sequence: 42,
|
|
50
|
+
resumeGatewayUrl: 'wss://gateway.discord.gg',
|
|
51
|
+
botUserId: 'bot-1',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function baseInput(settings: Record<string, unknown>) {
|
|
55
|
+
return {
|
|
56
|
+
channelId: CHANNEL,
|
|
57
|
+
settings,
|
|
58
|
+
scope: { tenantId: TENANT, organizationId: ORG },
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('channel_discord.channel.update_ai_auto_reply', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
findOne.mockReset()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('arms the channel while carrying the gateway resume state forward', async () => {
|
|
68
|
+
const channel = channelRow({ ...gatewayState })
|
|
69
|
+
findOne.mockResolvedValueOnce(channel)
|
|
70
|
+
|
|
71
|
+
const result = await updateAiAutoReplyCommand.execute(
|
|
72
|
+
baseInput({ aiAutoReplyEnabled: true, aiAgentId: 'channel_discord.auto_reply' }) as never,
|
|
73
|
+
makeCtx(),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(result).toMatchObject({
|
|
77
|
+
status: 'updated',
|
|
78
|
+
aiAutoReplyEnabled: true,
|
|
79
|
+
aiAgentId: 'channel_discord.auto_reply',
|
|
80
|
+
})
|
|
81
|
+
expect(channel.channelState).toEqual({
|
|
82
|
+
...gatewayState,
|
|
83
|
+
aiAutoReplyEnabled: true,
|
|
84
|
+
aiAgentId: 'channel_discord.auto_reply',
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('clears the agent when auto-reply is switched off, so stored state stays honest', async () => {
|
|
89
|
+
const channel = channelRow({ ...gatewayState, aiAutoReplyEnabled: true, aiAgentId: 'channel_discord.auto_reply' })
|
|
90
|
+
findOne.mockResolvedValueOnce(channel)
|
|
91
|
+
|
|
92
|
+
const result = await updateAiAutoReplyCommand.execute(
|
|
93
|
+
baseInput({ aiAutoReplyEnabled: false }) as never,
|
|
94
|
+
makeCtx(),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
expect(result).toMatchObject({ status: 'updated', aiAutoReplyEnabled: false, aiAgentId: null })
|
|
98
|
+
expect((channel.channelState as { aiAgentId?: string }).aiAgentId).toBeUndefined()
|
|
99
|
+
expect(channel.channelState).toMatchObject(gatewayState)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('refuses to arm a channel without naming an agent', async () => {
|
|
103
|
+
await expect(
|
|
104
|
+
updateAiAutoReplyCommand.execute(baseInput({ aiAutoReplyEnabled: true }) as never, makeCtx()),
|
|
105
|
+
).rejects.toThrow()
|
|
106
|
+
expect(findOne).not.toHaveBeenCalled()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('scopes the lookup to the tenant and the discord provider', async () => {
|
|
110
|
+
findOne.mockResolvedValueOnce(channelRow({}))
|
|
111
|
+
|
|
112
|
+
await updateAiAutoReplyCommand.execute(
|
|
113
|
+
baseInput({ aiAutoReplyEnabled: true, aiAgentId: 'channel_discord.auto_reply' }) as never,
|
|
114
|
+
makeCtx(),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const [, , where] = findOne.mock.calls[0]
|
|
118
|
+
expect(where).toMatchObject({
|
|
119
|
+
id: CHANNEL,
|
|
120
|
+
tenantId: TENANT,
|
|
121
|
+
providerKey: 'discord',
|
|
122
|
+
deletedAt: null,
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('keeps a tenant-scoped bot channel reachable while narrowing to the caller’s organizations', async () => {
|
|
127
|
+
findOne.mockResolvedValueOnce(channelRow({}))
|
|
128
|
+
|
|
129
|
+
await updateAiAutoReplyCommand.execute(
|
|
130
|
+
{
|
|
131
|
+
...baseInput({ aiAutoReplyEnabled: false }),
|
|
132
|
+
scope: { tenantId: TENANT, organizationId: ORG, organizationIds: [ORG] },
|
|
133
|
+
} as never,
|
|
134
|
+
makeCtx(),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const [, , where] = findOne.mock.calls[0]
|
|
138
|
+
// A Discord bot channel normally has `organization_id IS NULL`; an equality
|
|
139
|
+
// filter on the caller's organization would hide it outright.
|
|
140
|
+
expect(where.$or).toEqual([{ organizationId: { $in: [ORG] } }, { organizationId: null }])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('masks a channel it cannot see rather than reporting why', async () => {
|
|
144
|
+
findOne.mockResolvedValueOnce(null)
|
|
145
|
+
|
|
146
|
+
const result = await updateAiAutoReplyCommand.execute(
|
|
147
|
+
baseInput({ aiAutoReplyEnabled: false }) as never,
|
|
148
|
+
makeCtx(),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
expect(result).toEqual({ status: 'not_found' })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('rejects a save from a tab that loaded the channel before someone else changed it', async () => {
|
|
155
|
+
const channel = channelRow({ ...gatewayState })
|
|
156
|
+
findOne.mockResolvedValueOnce(channel)
|
|
157
|
+
const staleRequest = new Request('https://example.test/', {
|
|
158
|
+
headers: { [OPTIMISTIC_LOCK_HEADER_NAME]: new Date('2026-07-01T09:00:00.000Z').toISOString() },
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const failure = await updateAiAutoReplyCommand
|
|
162
|
+
.execute(baseInput({ aiAutoReplyEnabled: false }) as never, makeCtx(staleRequest))
|
|
163
|
+
.catch((err: unknown) => err)
|
|
164
|
+
|
|
165
|
+
expect(isCrudHttpError(failure)).toBe(true)
|
|
166
|
+
if (isCrudHttpError(failure)) expect(failure.status).toBe(409)
|
|
167
|
+
// The stale save must not have touched the row on its way out.
|
|
168
|
+
expect(channel.channelState).toEqual(gatewayState)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('accepts a save whose expected version matches the current one', async () => {
|
|
172
|
+
const channel = channelRow({ ...gatewayState })
|
|
173
|
+
findOne.mockResolvedValueOnce(channel)
|
|
174
|
+
const freshRequest = new Request('https://example.test/', {
|
|
175
|
+
headers: { [OPTIMISTIC_LOCK_HEADER_NAME]: UPDATED_AT.toISOString() },
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const result = await updateAiAutoReplyCommand.execute(
|
|
179
|
+
baseInput({ aiAutoReplyEnabled: false }) as never,
|
|
180
|
+
makeCtx(freshRequest),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
expect(result).toMatchObject({ status: 'updated' })
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { CommandBus, CommandHandler } from '@open-mercato/shared/lib/commands'
|
|
4
|
+
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
5
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
7
|
+
import { Message } from '@open-mercato/core/modules/messages/data/entities'
|
|
8
|
+
import {
|
|
9
|
+
CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
|
|
10
|
+
CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,
|
|
11
|
+
} from '../lib/ai-proposal-contract'
|
|
12
|
+
import { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from '../message-types'
|
|
13
|
+
|
|
14
|
+
const logger = createLogger('channel_discord').child({ component: 'ai-reply-proposal' })
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Commands behind the `complex`-tier approval card (issue #4778).
|
|
18
|
+
*
|
|
19
|
+
* `messages.actions.execute` dispatches these after it has atomically claimed
|
|
20
|
+
* the terminal action, so both handlers can assume they run at most once per
|
|
21
|
+
* proposal. It passes `messageId` / `actionId` and the acting operator in
|
|
22
|
+
* `ctx.auth` — everything else these commands need is read from the stored
|
|
23
|
+
* proposal, never from the request body, so a caller cannot redirect the send.
|
|
24
|
+
*/
|
|
25
|
+
const proposalActionInputSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
messageId: z.string().uuid(),
|
|
28
|
+
actionId: z.string().min(1),
|
|
29
|
+
})
|
|
30
|
+
.passthrough()
|
|
31
|
+
|
|
32
|
+
type ProposalActionInput = z.infer<typeof proposalActionInputSchema>
|
|
33
|
+
|
|
34
|
+
export type ApproveProposalResult = { ok: true; sentMessageId: string; threadId: string | null }
|
|
35
|
+
export type DismissProposalResult = { ok: true; dismissed: true }
|
|
36
|
+
|
|
37
|
+
type ResolvedScope = { tenantId: string; organizationId: string | null; userId: string }
|
|
38
|
+
|
|
39
|
+
function requireScope(auth: { sub?: string; tenantId?: string | null; orgId?: string | null } | null): ResolvedScope {
|
|
40
|
+
const userId = auth?.sub
|
|
41
|
+
const tenantId = auth?.tenantId
|
|
42
|
+
if (!userId || !tenantId) {
|
|
43
|
+
throw new Error('[internal] channel_discord AI proposal action requires an authenticated actor')
|
|
44
|
+
}
|
|
45
|
+
return { tenantId, organizationId: auth?.orgId ?? null, userId }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function loadProposal(
|
|
49
|
+
em: EntityManager,
|
|
50
|
+
messageId: string,
|
|
51
|
+
scope: ResolvedScope,
|
|
52
|
+
): Promise<Message> {
|
|
53
|
+
const proposal = await findOneWithDecryption(
|
|
54
|
+
em,
|
|
55
|
+
Message,
|
|
56
|
+
{ id: messageId, tenantId: scope.tenantId, deletedAt: null },
|
|
57
|
+
undefined,
|
|
58
|
+
{ tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
59
|
+
)
|
|
60
|
+
if (!proposal) throw new Error('[internal] AI reply proposal not found')
|
|
61
|
+
if (proposal.type !== CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE) {
|
|
62
|
+
// The action is only ever rendered on our own message type; a mismatch means
|
|
63
|
+
// the id was swapped, so refuse rather than sending an unrelated message body
|
|
64
|
+
// into a Discord server.
|
|
65
|
+
throw new Error('[internal] Message is not a Discord AI reply proposal')
|
|
66
|
+
}
|
|
67
|
+
return proposal
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const approveProposalCommand: CommandHandler<ProposalActionInput, ApproveProposalResult> = {
|
|
71
|
+
id: CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
|
|
72
|
+
// A message posted into a Discord server cannot be recalled by undoing a row
|
|
73
|
+
// here, so the command declines to pretend otherwise.
|
|
74
|
+
isUndoable: false,
|
|
75
|
+
async execute(rawInput, ctx) {
|
|
76
|
+
const input = proposalActionInputSchema.parse(rawInput)
|
|
77
|
+
const scope = requireScope(ctx.auth)
|
|
78
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
79
|
+
|
|
80
|
+
const proposal = await loadProposal(em, input.messageId, scope)
|
|
81
|
+
const inboundMessageId = proposal.sourceEntityId
|
|
82
|
+
if (!inboundMessageId) {
|
|
83
|
+
throw new Error('[internal] AI reply proposal has no source message to reply to')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const inbound = await findOneWithDecryption(
|
|
87
|
+
em,
|
|
88
|
+
Message,
|
|
89
|
+
{ id: inboundMessageId, tenantId: scope.tenantId, deletedAt: null },
|
|
90
|
+
undefined,
|
|
91
|
+
{ tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
92
|
+
)
|
|
93
|
+
if (!inbound) {
|
|
94
|
+
throw new Error('[internal] The message this proposal answers no longer exists')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const body = (proposal.body ?? '').trim()
|
|
98
|
+
if (!body) {
|
|
99
|
+
throw new Error('[internal] AI reply proposal has an empty body')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const commandBus = ctx.container.resolve('commandBus') as CommandBus
|
|
103
|
+
// Same generic outbound path the easy tier uses: compose a public reply in
|
|
104
|
+
// the thread and let the hub's outbound bridge deliver it. Attribution is the
|
|
105
|
+
// approving operator, not the bot — a human decided to send this.
|
|
106
|
+
const composeResult = await commandBus.execute<
|
|
107
|
+
Record<string, unknown>,
|
|
108
|
+
{ id: string; threadId: string | null }
|
|
109
|
+
>('messages.messages.compose', {
|
|
110
|
+
input: {
|
|
111
|
+
type: inbound.type,
|
|
112
|
+
visibility: 'public' as const,
|
|
113
|
+
subject: (inbound.subject ?? 'Discord reply').toString().slice(0, 200) || 'Discord reply',
|
|
114
|
+
body,
|
|
115
|
+
bodyFormat: 'markdown' as const,
|
|
116
|
+
priority: 'normal' as const,
|
|
117
|
+
sendViaEmail: false,
|
|
118
|
+
parentMessageId: inbound.threadId ?? inbound.id,
|
|
119
|
+
isDraft: false,
|
|
120
|
+
tenantId: scope.tenantId,
|
|
121
|
+
organizationId: scope.organizationId,
|
|
122
|
+
userId: scope.userId,
|
|
123
|
+
},
|
|
124
|
+
ctx: {
|
|
125
|
+
container: ctx.container,
|
|
126
|
+
auth: ctx.auth ?? null,
|
|
127
|
+
organizationScope: null,
|
|
128
|
+
selectedOrganizationId: scope.organizationId,
|
|
129
|
+
organizationIds: scope.organizationId ? [scope.organizationId] : null,
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const sent = composeResult.result
|
|
134
|
+
logger.info('AI reply proposal approved and sent', {
|
|
135
|
+
proposalId: proposal.id,
|
|
136
|
+
sentMessageId: sent.id,
|
|
137
|
+
})
|
|
138
|
+
return { ok: true, sentMessageId: sent.id, threadId: sent.threadId ?? null }
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const dismissProposalCommand: CommandHandler<ProposalActionInput, DismissProposalResult> = {
|
|
143
|
+
id: CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,
|
|
144
|
+
isUndoable: false,
|
|
145
|
+
async execute(rawInput, ctx) {
|
|
146
|
+
const input = proposalActionInputSchema.parse(rawInput)
|
|
147
|
+
const scope = requireScope(ctx.auth)
|
|
148
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
149
|
+
|
|
150
|
+
// Nothing to write: `messages.actions.execute` has already recorded the
|
|
151
|
+
// terminal action, which is what "dismissed" means. The load is the guard —
|
|
152
|
+
// it keeps the dismiss action from acknowledging a message that is not ours.
|
|
153
|
+
const proposal = await loadProposal(em, input.messageId, scope)
|
|
154
|
+
logger.info('AI reply proposal dismissed', { proposalId: proposal.id })
|
|
155
|
+
return { ok: true, dismissed: true }
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
registerCommand(approveProposalCommand as CommandHandler<unknown, unknown>)
|
|
160
|
+
registerCommand(dismissProposalCommand as CommandHandler<unknown, unknown>)
|
|
161
|
+
|
|
162
|
+
export { approveProposalCommand, dismissProposalCommand }
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { CommandHandler } from '@open-mercato/shared/lib/commands'
|
|
4
|
+
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
5
|
+
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
6
|
+
import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
7
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
8
|
+
import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
|
|
9
|
+
import { channelOrgScopeWhereFromFilter } from '@open-mercato/core/modules/communication_channels/lib/access-control'
|
|
10
|
+
import { discordChannelStateSchema } from '../lib/credentials'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resource kind the channel's optimistic-lock check is scoped under. It matches
|
|
14
|
+
* the `resourceKind` the route's mutation guard uses, so an operator's stale tab
|
|
15
|
+
* fails the same way here as it would on any other channel write.
|
|
16
|
+
*/
|
|
17
|
+
export const CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND = 'communication_channels.channel'
|
|
18
|
+
|
|
19
|
+
export const CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID =
|
|
20
|
+
'channel_discord.channel.update_ai_auto_reply'
|
|
21
|
+
|
|
22
|
+
export const discordAiAutoReplySettingsSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
aiAutoReplyEnabled: z.boolean(),
|
|
25
|
+
aiAgentId: z.string().trim().min(1).max(200).optional(),
|
|
26
|
+
})
|
|
27
|
+
.refine((value) => !value.aiAutoReplyEnabled || Boolean(value.aiAgentId), {
|
|
28
|
+
// Arming auto-reply without naming an agent is the state that made the
|
|
29
|
+
// original subscriber dormant; the schema refuses to reproduce it.
|
|
30
|
+
path: ['aiAgentId'],
|
|
31
|
+
message: 'channel_discord.aiAutoReply.errors.agentRequired',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export type DiscordAiAutoReplySettings = z.infer<typeof discordAiAutoReplySettingsSchema>
|
|
35
|
+
|
|
36
|
+
const updateAiAutoReplySchema = z.object({
|
|
37
|
+
channelId: z.string().uuid(),
|
|
38
|
+
settings: discordAiAutoReplySettingsSchema,
|
|
39
|
+
scope: z.object({
|
|
40
|
+
tenantId: z.string().uuid(),
|
|
41
|
+
/** Organization the decryption scope is anchored to. */
|
|
42
|
+
organizationId: z.string().uuid().nullable(),
|
|
43
|
+
/**
|
|
44
|
+
* Organizations the caller may see, as resolved by the route. A Discord bot
|
|
45
|
+
* channel is usually tenant-scoped (`organization_id IS NULL`), so filtering
|
|
46
|
+
* on a single organization would hide the very channels this command
|
|
47
|
+
* configures — the hub's `channelOrgScopeWhereFromFilter` encodes the right
|
|
48
|
+
* rule and is reused here.
|
|
49
|
+
*/
|
|
50
|
+
organizationIds: z.array(z.string().uuid()).nullable().optional(),
|
|
51
|
+
}),
|
|
52
|
+
expectedUpdatedAt: z.string().optional(),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export type UpdateAiAutoReplyInput = z.infer<typeof updateAiAutoReplySchema>
|
|
56
|
+
|
|
57
|
+
export type UpdateAiAutoReplyResult =
|
|
58
|
+
| {
|
|
59
|
+
status: 'updated'
|
|
60
|
+
channelId: string
|
|
61
|
+
aiAutoReplyEnabled: boolean
|
|
62
|
+
aiAgentId: string | null
|
|
63
|
+
updatedAt: string | null
|
|
64
|
+
}
|
|
65
|
+
| { status: 'not_found' }
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Persist a Discord channel's AI auto-reply settings (issue #4778).
|
|
69
|
+
*
|
|
70
|
+
* The write goes through the command bus rather than an entity write in the
|
|
71
|
+
* route so it inherits the platform's audit trail, and so the two keys the
|
|
72
|
+
* subscriber reads have exactly one writer.
|
|
73
|
+
*
|
|
74
|
+
* Two things it is careful about:
|
|
75
|
+
*
|
|
76
|
+
* - **It merges.** `channelState` is shared with the gateway worker, which writes
|
|
77
|
+
* resume state (`sessionId`, `sequence`, `resumeGatewayUrl`, `botUserId`) from
|
|
78
|
+
* a socket callback. Replacing the blob would drop a live session's cursor and
|
|
79
|
+
* force a re-IDENTIFY, so only the two AI keys are touched — the mirror image
|
|
80
|
+
* of what `lib/channel-state-store.ts` does from the other side.
|
|
81
|
+
* - **It version-checks.** The settings form sends the channel's `updatedAt`, so
|
|
82
|
+
* a save from a tab opened before someone else changed the channel fails with
|
|
83
|
+
* the standard 409 conflict body instead of silently winning.
|
|
84
|
+
*/
|
|
85
|
+
const updateAiAutoReplyCommand: CommandHandler<UpdateAiAutoReplyInput, UpdateAiAutoReplyResult> = {
|
|
86
|
+
id: CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,
|
|
87
|
+
// Not undoable on purpose: replaying a stale snapshot could re-arm auto-reply
|
|
88
|
+
// on a channel an operator deliberately disarmed — for example after the bot
|
|
89
|
+
// answered something it should not have. Reverting is a deliberate act through
|
|
90
|
+
// the same form, which leaves its own audit entry.
|
|
91
|
+
isUndoable: false,
|
|
92
|
+
async execute(rawInput, ctx) {
|
|
93
|
+
const input = updateAiAutoReplySchema.parse(rawInput)
|
|
94
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
95
|
+
const dscope = { tenantId: input.scope.tenantId, organizationId: input.scope.organizationId }
|
|
96
|
+
|
|
97
|
+
const channel = await findOneWithDecryption(
|
|
98
|
+
em,
|
|
99
|
+
CommunicationChannel,
|
|
100
|
+
{
|
|
101
|
+
id: input.channelId,
|
|
102
|
+
tenantId: input.scope.tenantId,
|
|
103
|
+
providerKey: 'discord',
|
|
104
|
+
...channelOrgScopeWhereFromFilter({ organizationIds: input.scope.organizationIds ?? undefined }),
|
|
105
|
+
deletedAt: null,
|
|
106
|
+
},
|
|
107
|
+
undefined,
|
|
108
|
+
dscope,
|
|
109
|
+
)
|
|
110
|
+
// Existence masking, consistent with the hub's channel-scoped routes: a
|
|
111
|
+
// channel in another tenant and a channel that never existed answer alike.
|
|
112
|
+
if (!channel) return { status: 'not_found' }
|
|
113
|
+
|
|
114
|
+
// The DI-aware seam, not the bare synchronous helper: it runs the OSS
|
|
115
|
+
// `updated_at` floor first and then awaits the optional enterprise
|
|
116
|
+
// `record_locks` guard, so a channel someone else holds a lock on is
|
|
117
|
+
// protected on an enterprise build too.
|
|
118
|
+
await enforceCommandOptimisticLockWithGuards(ctx.container, {
|
|
119
|
+
resourceKind: CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,
|
|
120
|
+
resourceId: channel.id,
|
|
121
|
+
current: channel.updatedAt,
|
|
122
|
+
expected: input.expectedUpdatedAt ?? null,
|
|
123
|
+
request: ctx.request ?? null,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const current = discordChannelStateSchema.parse(channel.channelState ?? {})
|
|
127
|
+
const aiAgentId = input.settings.aiAutoReplyEnabled ? input.settings.aiAgentId : undefined
|
|
128
|
+
|
|
129
|
+
await withAtomicFlush(
|
|
130
|
+
em,
|
|
131
|
+
[
|
|
132
|
+
() => {
|
|
133
|
+
channel.channelState = {
|
|
134
|
+
...current,
|
|
135
|
+
aiAutoReplyEnabled: input.settings.aiAutoReplyEnabled,
|
|
136
|
+
// Clearing the agent when auto-reply is switched off keeps the stored
|
|
137
|
+
// state honest: a disabled channel does not keep pointing at an agent.
|
|
138
|
+
aiAgentId,
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
{ transaction: true, label: 'channel_discord.channel.update_ai_auto_reply' },
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
status: 'updated',
|
|
147
|
+
channelId: channel.id,
|
|
148
|
+
aiAutoReplyEnabled: input.settings.aiAutoReplyEnabled,
|
|
149
|
+
aiAgentId: aiAgentId ?? null,
|
|
150
|
+
updatedAt: channel.updatedAt ? channel.updatedAt.toISOString() : null,
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
registerCommand(updateAiAutoReplyCommand as CommandHandler<unknown, unknown>)
|
|
156
|
+
|
|
157
|
+
export default updateAiAutoReplyCommand
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { asValue } from 'awilix'
|
|
2
|
+
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import {
|
|
4
|
+
hasChannelAdapter,
|
|
5
|
+
registerChannelAdapter,
|
|
6
|
+
} from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
|
|
7
|
+
import { getDiscordChannelAdapter } from './lib/adapter'
|
|
8
|
+
import { channelDiscordHealthCheck } from './lib/health'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Re-register the adapter on container creation as a safety net for runtime
|
|
12
|
+
* environments that bypass module setup (worker-only nodes, ad-hoc CLI). The
|
|
13
|
+
* underlying registry is process-wide so the registration is idempotent.
|
|
14
|
+
*/
|
|
15
|
+
export function register(container: AppContainer): void {
|
|
16
|
+
if (!hasChannelAdapter('discord')) {
|
|
17
|
+
registerChannelAdapter(getDiscordChannelAdapter())
|
|
18
|
+
}
|
|
19
|
+
container.register({
|
|
20
|
+
channelDiscordAdapter: asValue(getDiscordChannelAdapter()),
|
|
21
|
+
// Registered under the exact service name declared in `integration.ts`
|
|
22
|
+
// (`healthCheck.service`). Without this, the hub's `container.resolve(...)`
|
|
23
|
+
// throws and the channel reports permanently 'unhealthy'.
|
|
24
|
+
channelDiscordHealthCheck: asValue(channelDiscordHealthCheck),
|
|
25
|
+
})
|
|
26
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"channel_discord.aiAutoReply.errors.agentFeaturesMissing": "Dem Kanal-Bot-Benutzer fehlen die von diesem Agenten benötigten Berechtigungen: {features}. Vergeben Sie sie zuerst oder wählen Sie einen anderen Agenten.",
|
|
3
|
+
"channel_discord.aiAutoReply.errors.agentNotEligible": "Dieser Agent kann nicht auf einem Kanal antworten — wählen Sie einen mit strukturierter Ausgabe",
|
|
4
|
+
"channel_discord.aiAutoReply.errors.agentRequired": "Wählen Sie den Agenten, der auf diesem Kanal antworten soll",
|
|
5
|
+
"channel_discord.aiAutoReply.errors.aiUnavailable": "Die Autoantwort benötigt das KI-Assistent-Modul, das in dieser Installation fehlt",
|
|
6
|
+
"channel_discord.aiAutoReply.errors.load": "KI-Autoantwort-Einstellungen konnten nicht geladen werden",
|
|
7
|
+
"channel_discord.aiAutoReply.errors.loadChannels": "Discord-Kanäle konnten nicht geladen werden",
|
|
8
|
+
"channel_discord.aiAutoReply.errors.save": "KI-Autoantwort-Einstellungen konnten nicht gespeichert werden",
|
|
9
|
+
"channel_discord.aiAutoReply.fields.agent": "Agent",
|
|
10
|
+
"channel_discord.aiAutoReply.fields.agentHelp": "Nur Agenten mit strukturierter Ausgabe können auf einem Kanal antworten. Geben Sie dem Kanal-Bot-Benutzer des Mandanten die vom Agenten geforderten Berechtigungen, bevor Sie einen Agenten aus einem anderen Modul wählen.",
|
|
11
|
+
"channel_discord.aiAutoReply.fields.agentMissingFeatures": "{label} — benötigt {features}",
|
|
12
|
+
"channel_discord.aiAutoReply.fields.enabled": "Eingehende Discord-Nachrichten mit KI beantworten",
|
|
13
|
+
"channel_discord.aiAutoReply.fields.enabledHelp": "Standardmäßig aus. Wenn aktiviert, werden einfache Nachrichten automatisch beantwortet; alles Sensible, Unsichere oder Verdächtige wird stattdessen einem Menschen zur Freigabe vorgelegt.",
|
|
14
|
+
"channel_discord.aiAutoReply.intro": "Legen Sie fest, ob dieser Discord-Kanal selbstständig antworten darf und welcher Agent die Antwort entwirft.",
|
|
15
|
+
"channel_discord.aiAutoReply.lastError": "Der letzte Autoantwort-Versuch auf diesem Kanal ergab nichts: {reason}",
|
|
16
|
+
"channel_discord.aiAutoReply.lastErrorAt": "(erstmals {at})",
|
|
17
|
+
"channel_discord.aiAutoReply.loading": "KI-Autoantwort-Einstellungen werden geladen...",
|
|
18
|
+
"channel_discord.aiAutoReply.loadingChannels": "Discord-Kanäle werden geladen...",
|
|
19
|
+
"channel_discord.aiAutoReply.notFound": "Discord-Kanal nicht gefunden",
|
|
20
|
+
"channel_discord.aiAutoReply.page.title": "Discord KI-Autoantwort",
|
|
21
|
+
"channel_discord.aiAutoReply.save": "Speichern",
|
|
22
|
+
"channel_discord.aiAutoReply.saved": "KI-Autoantwort-Einstellungen gespeichert.",
|
|
23
|
+
"channel_discord.aiAutoReply.unavailable": "Das KI-Assistent-Modul ist in dieser Installation nicht vorhanden, daher lässt sich die Autoantwort nicht aktivieren. Der Kanal funktioniert weiterhin als normaler Posteingang.",
|
|
24
|
+
"channel_discord.aiAutoReply.widget.configure": "Konfigurieren",
|
|
25
|
+
"channel_discord.aiAutoReply.widget.description": "Lassen Sie einen KI-Agenten eingehende Discord-Nachrichten beantworten. Jeder Kanal ist standardmäßig aus, und alles Sensible oder Unsichere wird einem Menschen zur Freigabe vorgelegt, statt gesendet zu werden.",
|
|
26
|
+
"channel_discord.aiAutoReply.widget.failing": "Autoantwort fehlerhaft",
|
|
27
|
+
"channel_discord.aiAutoReply.widget.lastError": "Letzter Versuch fehlgeschlagen: {reason}",
|
|
28
|
+
"channel_discord.aiAutoReply.widget.noAgent": "Kein Agent ausgewählt",
|
|
29
|
+
"channel_discord.aiAutoReply.widget.noChannels": "Verbinden Sie zuerst einen Discord-Bot — die Autoantwort wird pro Kanal konfiguriert.",
|
|
30
|
+
"channel_discord.aiAutoReply.widget.off": "Autoantwort aus",
|
|
31
|
+
"channel_discord.aiAutoReply.widget.on": "Autoantwort an",
|
|
32
|
+
"channel_discord.aiAutoReply.widget.title": "KI-Autoantwort",
|
|
33
|
+
"channel_discord.aiAutoReply.widget.truncated": "Es werden nur die neuesten Kanäle angezeigt. Öffnen Sie die übrigen über die Kanalliste, um sie zu konfigurieren.",
|
|
34
|
+
"channel_discord.aiProposal.actions.approve": "Freigeben und senden",
|
|
35
|
+
"channel_discord.aiProposal.actions.dismiss": "Verwerfen",
|
|
36
|
+
"channel_discord.aiProposal.messageType": "Vorgeschlagene Discord-Antwort",
|
|
37
|
+
"channel_discord.connect.blocked": "Verbindung durch Validierung blockiert",
|
|
38
|
+
"channel_discord.connect.button": "Discord verbinden",
|
|
39
|
+
"channel_discord.connect.cancel": "Abbrechen",
|
|
40
|
+
"channel_discord.connect.connected": "Discord-Kanal verbunden.",
|
|
41
|
+
"channel_discord.connect.connecting": "Verbinde...",
|
|
42
|
+
"channel_discord.connect.failed": "Discord-Bot konnte nicht verbunden werden.",
|
|
43
|
+
"channel_discord.connect.fields.applicationId": "Anwendungs-ID",
|
|
44
|
+
"channel_discord.connect.fields.botToken": "Bot-Token",
|
|
45
|
+
"channel_discord.connect.fields.defaultChannelId": "Standard-Kanal-ID",
|
|
46
|
+
"channel_discord.connect.fields.displayName": "Anzeigename",
|
|
47
|
+
"channel_discord.connect.fields.guildId": "Server-ID (Guild)",
|
|
48
|
+
"channel_discord.connect.fields.publicKey": "Öffentlicher Schlüssel",
|
|
49
|
+
"channel_discord.connect.save": "Verbinden",
|
|
50
|
+
"channel_discord.connect.title": "Discord-Bot verbinden",
|
|
51
|
+
"channel_discord.interactions.commandReceived": "Empfangen — `/{command}` liegt jetzt im Open-Mercato-Posteingang und wird von dort bearbeitet.",
|
|
52
|
+
"channel_discord.interactions.componentReceived": "Empfangen — Deine Antwort liegt jetzt im Open-Mercato-Posteingang und wird von dort bearbeitet.",
|
|
53
|
+
"channel_discord.interactions.dispatchFailed": "Open Mercato konnte diese Interaktion gerade nicht erfassen. Bitte versuche es gleich noch einmal.",
|
|
54
|
+
"channel_discord.interactions.notDispatchable": "Dieser Interaktion fehlt der Kanal- oder Benutzerkontext, den Open Mercato benötigt, daher wurde sie nicht erfasst.",
|
|
55
|
+
"channel_discord.interactions.unsupported": "Der Discord-Kanal von Open Mercato verarbeitet diesen Interaktionstyp nicht.",
|
|
56
|
+
"channel_discord.provider.label": "Discord"
|
|
57
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"channel_discord.aiAutoReply.errors.agentFeaturesMissing": "The channel-bot user is missing the features this agent needs: {features}. Grant them first, or pick another agent.",
|
|
3
|
+
"channel_discord.aiAutoReply.errors.agentNotEligible": "That agent cannot answer a channel — pick one that produces structured output",
|
|
4
|
+
"channel_discord.aiAutoReply.errors.agentRequired": "Choose the agent that should answer this channel",
|
|
5
|
+
"channel_discord.aiAutoReply.errors.aiUnavailable": "Auto-reply needs the AI assistant module, which is not installed in this deployment",
|
|
6
|
+
"channel_discord.aiAutoReply.errors.load": "Failed to load AI auto-reply settings",
|
|
7
|
+
"channel_discord.aiAutoReply.errors.loadChannels": "Failed to load Discord channels",
|
|
8
|
+
"channel_discord.aiAutoReply.errors.save": "Failed to save AI auto-reply settings",
|
|
9
|
+
"channel_discord.aiAutoReply.fields.agent": "Agent",
|
|
10
|
+
"channel_discord.aiAutoReply.fields.agentHelp": "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.",
|
|
11
|
+
"channel_discord.aiAutoReply.fields.agentMissingFeatures": "{label} — needs {features}",
|
|
12
|
+
"channel_discord.aiAutoReply.fields.enabled": "Answer inbound Discord messages with AI",
|
|
13
|
+
"channel_discord.aiAutoReply.fields.enabledHelp": "Off by default. When on, straightforward messages are answered automatically; anything sensitive, low-confidence or suspicious is proposed for a human to approve instead.",
|
|
14
|
+
"channel_discord.aiAutoReply.intro": "Choose whether this Discord channel may answer on its own, and which agent drafts the reply.",
|
|
15
|
+
"channel_discord.aiAutoReply.lastError": "The last auto-reply attempt on this channel produced nothing: {reason}",
|
|
16
|
+
"channel_discord.aiAutoReply.lastErrorAt": "(first seen {at})",
|
|
17
|
+
"channel_discord.aiAutoReply.loading": "Loading AI auto-reply settings...",
|
|
18
|
+
"channel_discord.aiAutoReply.loadingChannels": "Loading Discord channels...",
|
|
19
|
+
"channel_discord.aiAutoReply.notFound": "Discord channel not found",
|
|
20
|
+
"channel_discord.aiAutoReply.page.title": "Discord AI auto-reply",
|
|
21
|
+
"channel_discord.aiAutoReply.save": "Save",
|
|
22
|
+
"channel_discord.aiAutoReply.saved": "AI auto-reply settings saved.",
|
|
23
|
+
"channel_discord.aiAutoReply.unavailable": "The AI assistant module is not installed in this deployment, so auto-reply cannot be enabled. The channel keeps working as a normal inbox.",
|
|
24
|
+
"channel_discord.aiAutoReply.widget.configure": "Configure",
|
|
25
|
+
"channel_discord.aiAutoReply.widget.description": "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.",
|
|
26
|
+
"channel_discord.aiAutoReply.widget.failing": "Auto-reply failing",
|
|
27
|
+
"channel_discord.aiAutoReply.widget.lastError": "Last attempt failed: {reason}",
|
|
28
|
+
"channel_discord.aiAutoReply.widget.noAgent": "No agent selected",
|
|
29
|
+
"channel_discord.aiAutoReply.widget.noChannels": "Connect a Discord bot first — auto-reply is configured per channel.",
|
|
30
|
+
"channel_discord.aiAutoReply.widget.off": "Auto-reply off",
|
|
31
|
+
"channel_discord.aiAutoReply.widget.on": "Auto-reply on",
|
|
32
|
+
"channel_discord.aiAutoReply.widget.title": "AI auto-reply",
|
|
33
|
+
"channel_discord.aiAutoReply.widget.truncated": "Only the most recent channels are shown. Open a channel from the Channels page to configure the rest.",
|
|
34
|
+
"channel_discord.aiProposal.actions.approve": "Approve and send",
|
|
35
|
+
"channel_discord.aiProposal.actions.dismiss": "Dismiss",
|
|
36
|
+
"channel_discord.aiProposal.messageType": "Proposed Discord reply",
|
|
37
|
+
"channel_discord.connect.blocked": "Connection blocked by validation",
|
|
38
|
+
"channel_discord.connect.button": "Connect Discord",
|
|
39
|
+
"channel_discord.connect.cancel": "Cancel",
|
|
40
|
+
"channel_discord.connect.connected": "Discord channel connected.",
|
|
41
|
+
"channel_discord.connect.connecting": "Connecting...",
|
|
42
|
+
"channel_discord.connect.failed": "Could not connect Discord bot.",
|
|
43
|
+
"channel_discord.connect.fields.applicationId": "Application ID",
|
|
44
|
+
"channel_discord.connect.fields.botToken": "Bot token",
|
|
45
|
+
"channel_discord.connect.fields.defaultChannelId": "Default channel ID",
|
|
46
|
+
"channel_discord.connect.fields.displayName": "Display name",
|
|
47
|
+
"channel_discord.connect.fields.guildId": "Guild (server) ID",
|
|
48
|
+
"channel_discord.connect.fields.publicKey": "Public key",
|
|
49
|
+
"channel_discord.connect.save": "Connect",
|
|
50
|
+
"channel_discord.connect.title": "Connect Discord bot",
|
|
51
|
+
"channel_discord.interactions.commandReceived": "Received — `/{command}` is now in the Open Mercato inbox and someone will pick it up from there.",
|
|
52
|
+
"channel_discord.interactions.componentReceived": "Received — your response is now in the Open Mercato inbox and someone will pick it up from there.",
|
|
53
|
+
"channel_discord.interactions.dispatchFailed": "Open Mercato could not record this interaction right now. Please try again in a moment.",
|
|
54
|
+
"channel_discord.interactions.notDispatchable": "This interaction is missing the channel or user context Open Mercato needs, so it was not recorded.",
|
|
55
|
+
"channel_discord.interactions.unsupported": "The Open Mercato Discord channel does not handle this interaction type.",
|
|
56
|
+
"channel_discord.provider.label": "Discord"
|
|
57
|
+
}
|