@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,99 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
6
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
7
|
+
import { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
|
|
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 { CHANNEL_DISCORD_VIEW_FEATURE } from '../../../lib/ai-features'
|
|
11
|
+
import { discordChannelStateSchema } from '../../../lib/credentials'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every Discord channel's AI auto-reply state in one call.
|
|
15
|
+
*
|
|
16
|
+
* The integration detail panel needs two booleans and an agent id per channel.
|
|
17
|
+
* Asking the per-channel settings route for them meant one request per channel
|
|
18
|
+
* *and* one full agent-registry load per request — 41 requests and 40 registry
|
|
19
|
+
* loads to render 40 rows on a page operators land on. Nothing about that panel
|
|
20
|
+
* needs the agent directory, so this route does not build one.
|
|
21
|
+
*
|
|
22
|
+
* The path is pinned in `metadata` rather than inferred from the file location,
|
|
23
|
+
* the way `api/interactions/route.ts` pins its own: the sibling settings route
|
|
24
|
+
* lives under `channels/[id]/`, and a collection endpoint that reads as a channel
|
|
25
|
+
* id (`/channels/ai-auto-reply`) is a URL waiting to be mistaken for one.
|
|
26
|
+
*/
|
|
27
|
+
export const metadata = {
|
|
28
|
+
path: '/channel_discord/ai-auto-reply/channels',
|
|
29
|
+
GET: {
|
|
30
|
+
requireAuth: true,
|
|
31
|
+
requireFeatures: [CHANNEL_DISCORD_VIEW_FEATURE],
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The panel lists a handful of bot channels; the cap is a guard against an
|
|
37
|
+
* unbounded scan, not a paging contract. `truncated` tells the client when it is
|
|
38
|
+
* looking at a partial list rather than letting it silently under-report.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_CHANNELS = 200
|
|
41
|
+
|
|
42
|
+
export async function GET(req: Request): Promise<Response> {
|
|
43
|
+
const auth = await getAuthFromRequest(req)
|
|
44
|
+
if (!auth?.sub || !auth?.tenantId) {
|
|
45
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const container = await createRequestContainer()
|
|
49
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
50
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth: auth as never, request: req })
|
|
51
|
+
const orgFilter = resolveOrganizationScopeFilter(scope, auth as never)
|
|
52
|
+
|
|
53
|
+
// Same scoping the hub's own channel list uses: a Discord bot channel is
|
|
54
|
+
// usually tenant-scoped (`organization_id IS NULL`), so an equality filter on
|
|
55
|
+
// the caller's selected organization would hide exactly the rows this panel is
|
|
56
|
+
// about (#5012). `userId: null` keeps personal mailboxes out, as the hub does.
|
|
57
|
+
const channels = await findWithDecryption(
|
|
58
|
+
em,
|
|
59
|
+
CommunicationChannel,
|
|
60
|
+
{
|
|
61
|
+
tenantId: auth.tenantId as string,
|
|
62
|
+
providerKey: 'discord',
|
|
63
|
+
...channelOrgScopeWhereFromFilter(orgFilter),
|
|
64
|
+
userId: null,
|
|
65
|
+
deletedAt: null,
|
|
66
|
+
},
|
|
67
|
+
{ limit: MAX_CHANNELS + 1, orderBy: { createdAt: 'desc' } },
|
|
68
|
+
{ tenantId: auth.tenantId as string, organizationId: orgFilter.rbacOrganizationId },
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const truncated = channels.length > MAX_CHANNELS
|
|
72
|
+
const items = (truncated ? channels.slice(0, MAX_CHANNELS) : channels).map((channel) => {
|
|
73
|
+
const state = discordChannelStateSchema.safeParse(channel.channelState ?? {})
|
|
74
|
+
return {
|
|
75
|
+
channelId: channel.id,
|
|
76
|
+
displayName: channel.displayName,
|
|
77
|
+
aiAutoReplyEnabled: state.success ? Boolean(state.data.aiAutoReplyEnabled) : false,
|
|
78
|
+
aiAgentId: (state.success ? state.data.aiAgentId : undefined) ?? null,
|
|
79
|
+
aiAutoReplyLastError: (state.success ? state.data.aiAutoReplyLastError : undefined) ?? null,
|
|
80
|
+
aiAutoReplyLastErrorAt: (state.success ? state.data.aiAutoReplyLastErrorAt : undefined) ?? null,
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
return NextResponse.json({ items, truncated })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const openApi = {
|
|
88
|
+
tags: ['CommunicationChannels'],
|
|
89
|
+
methods: {
|
|
90
|
+
GET: {
|
|
91
|
+
summary: 'List the AI auto-reply state of every Discord channel in scope',
|
|
92
|
+
tags: ['CommunicationChannels'],
|
|
93
|
+
responses: [
|
|
94
|
+
{ status: 200, description: 'One row per Discord channel the caller may see' },
|
|
95
|
+
{ status: 401, description: 'Unauthorized' },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Arming contract for the AI auto-reply settings route (issue #4778).
|
|
5
|
+
*
|
|
6
|
+
* The route already refused an absent AI peer and a non-object-mode agent. Shape
|
|
7
|
+
* is not authorization, though: `runAiAgentObject` → `checkAgentPolicy` also runs
|
|
8
|
+
* the agent's `requiredFeatures` against the principal from
|
|
9
|
+
* `lib/ai-service-principal.ts`, which carries only the provider grant unless the
|
|
10
|
+
* tenant created a channel-bot user with a wider role. An agent that passes the
|
|
11
|
+
* shape check and fails the feature check stored a setting that could only fail
|
|
12
|
+
* later, in a background subscriber where nobody sees it — the channel read
|
|
13
|
+
* "Auto-reply on" and answered nothing, forever.
|
|
14
|
+
*
|
|
15
|
+
* `missingAgentFeatures` is deliberately NOT mocked here: the point of the test
|
|
16
|
+
* is that the route asks the platform's real feature-policy question, wildcard
|
|
17
|
+
* grants included.
|
|
18
|
+
*/
|
|
19
|
+
const tenantId = '11111111-1111-4111-8111-111111111111'
|
|
20
|
+
const organizationId = '22222222-2222-4222-8222-222222222222'
|
|
21
|
+
const channelId = '33333333-3333-4333-8333-333333333333'
|
|
22
|
+
const userId = '44444444-4444-4444-8444-444444444444'
|
|
23
|
+
|
|
24
|
+
const PROVIDER_AGENT = {
|
|
25
|
+
id: 'channel_discord.auto_reply',
|
|
26
|
+
label: 'Discord auto-reply',
|
|
27
|
+
description: '',
|
|
28
|
+
requiredFeatures: ['channel_discord.ai_auto_reply.run'],
|
|
29
|
+
}
|
|
30
|
+
const FOREIGN_AGENT = {
|
|
31
|
+
id: 'customers.support',
|
|
32
|
+
label: 'Customer support',
|
|
33
|
+
description: '',
|
|
34
|
+
requiredFeatures: ['customers.view', 'customers.manage'],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const getAuthFromRequestMock = jest.fn()
|
|
38
|
+
const loadDiscordChannelForRequestMock = jest.fn()
|
|
39
|
+
const validateRouteMutationGuardMock = jest.fn()
|
|
40
|
+
const resolveDiscordAiPrincipalMock = jest.fn()
|
|
41
|
+
const listDiscordEligibleAgentsMock = jest.fn()
|
|
42
|
+
const commandBusExecuteMock = jest.fn()
|
|
43
|
+
|
|
44
|
+
const em = { fork: () => em }
|
|
45
|
+
const container = {
|
|
46
|
+
resolve: jest.fn((name: string) => {
|
|
47
|
+
if (name === 'em') return em
|
|
48
|
+
if (name === 'commandBus') return { execute: commandBusExecuteMock }
|
|
49
|
+
if (name === 'rbacService') throw new Error('not registered in this test')
|
|
50
|
+
throw new Error(`Unexpected container resolve: ${name}`)
|
|
51
|
+
}),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
55
|
+
getAuthFromRequest: (...args: unknown[]) => getAuthFromRequestMock(...args),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
59
|
+
createRequestContainer: jest.fn(async () => container),
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
jest.mock('@open-mercato/core/modules/communication_channels/lib/route-mutation-guard', () => ({
|
|
63
|
+
validateRouteMutationGuard: (...args: unknown[]) => validateRouteMutationGuardMock(...args),
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
jest.mock('../../../../../lib/channel-access', () => ({
|
|
67
|
+
loadDiscordChannelForRequest: (...args: unknown[]) => loadDiscordChannelForRequestMock(...args),
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
jest.mock('../../../../../lib/ai-service-principal', () => ({
|
|
71
|
+
resolveDiscordAiPrincipal: (...args: unknown[]) => resolveDiscordAiPrincipalMock(...args),
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
// Only the registry lookup is stubbed; the feature comparison under test is real.
|
|
75
|
+
jest.mock('../../../../../lib/ai-agent-directory', () => {
|
|
76
|
+
const actual = jest.requireActual('../../../../../lib/ai-agent-directory')
|
|
77
|
+
return {
|
|
78
|
+
...actual,
|
|
79
|
+
listDiscordEligibleAgents: (...args: unknown[]) => listDiscordEligibleAgentsMock(...args),
|
|
80
|
+
findDiscordEligibleAgent: async (agentId: string) => {
|
|
81
|
+
const directory = await listDiscordEligibleAgentsMock()
|
|
82
|
+
if (!directory.available) return null
|
|
83
|
+
return directory.agents.find((agent: { id: string }) => agent.id === agentId) ?? null
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
import { GET, PUT } from '../route'
|
|
89
|
+
|
|
90
|
+
const channel = {
|
|
91
|
+
id: channelId,
|
|
92
|
+
displayName: 'Discord bot',
|
|
93
|
+
updatedAt: new Date('2026-08-01T00:00:00.000Z'),
|
|
94
|
+
channelState: { aiAutoReplyEnabled: false },
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function putRequest(body: unknown): Request {
|
|
98
|
+
return new Request(`http://localhost/api/channel_discord/channels/${channelId}/ai-auto-reply`, {
|
|
99
|
+
method: 'PUT',
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getRequest(): Request {
|
|
105
|
+
return new Request(`http://localhost/api/channel_discord/channels/${channelId}/ai-auto-reply`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const context = { params: { id: channelId } }
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
jest.clearAllMocks()
|
|
112
|
+
getAuthFromRequestMock.mockResolvedValue({ sub: userId, tenantId, orgId: organizationId })
|
|
113
|
+
loadDiscordChannelForRequestMock.mockResolvedValue({
|
|
114
|
+
channel,
|
|
115
|
+
organizationIds: [organizationId],
|
|
116
|
+
rbacOrganizationId: organizationId,
|
|
117
|
+
})
|
|
118
|
+
validateRouteMutationGuardMock.mockResolvedValue({ afterSuccess: jest.fn(async () => {}) })
|
|
119
|
+
listDiscordEligibleAgentsMock.mockResolvedValue({
|
|
120
|
+
available: true,
|
|
121
|
+
agents: [PROVIDER_AGENT, FOREIGN_AGENT],
|
|
122
|
+
})
|
|
123
|
+
// The default tenant: no channel-bot user, so the provider service principal.
|
|
124
|
+
resolveDiscordAiPrincipalMock.mockResolvedValue({
|
|
125
|
+
tenantId,
|
|
126
|
+
organizationId,
|
|
127
|
+
userId,
|
|
128
|
+
features: ['channel_discord.ai_auto_reply.run'],
|
|
129
|
+
isSuperAdmin: false,
|
|
130
|
+
source: 'provider_service_principal',
|
|
131
|
+
})
|
|
132
|
+
commandBusExecuteMock.mockResolvedValue({
|
|
133
|
+
result: {
|
|
134
|
+
status: 'updated',
|
|
135
|
+
channelId,
|
|
136
|
+
aiAutoReplyEnabled: true,
|
|
137
|
+
aiAgentId: PROVIDER_AGENT.id,
|
|
138
|
+
updatedAt: '2026-08-02T00:00:00.000Z',
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('PUT /channel_discord/channels/{id}/ai-auto-reply — arming checks', () => {
|
|
144
|
+
it('refuses an agent whose required features the auto-reply principal lacks', async () => {
|
|
145
|
+
const response = await PUT(
|
|
146
|
+
putRequest({ aiAutoReplyEnabled: true, aiAgentId: FOREIGN_AGENT.id }),
|
|
147
|
+
context,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
expect(response.status).toBe(400)
|
|
151
|
+
const body = await response.json()
|
|
152
|
+
expect(body.fieldErrors.aiAgentId).toBe('channel_discord.aiAutoReply.errors.agentFeaturesMissing')
|
|
153
|
+
// The operator has to be told WHICH grants are missing; a bare refusal sends
|
|
154
|
+
// them back to the same dead end.
|
|
155
|
+
expect(body.missingFeatures).toEqual(['customers.view', 'customers.manage'])
|
|
156
|
+
// The refusal happens before anything is stored.
|
|
157
|
+
expect(commandBusExecuteMock).not.toHaveBeenCalled()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('arms the provider agent under the service principal with no operator setup', async () => {
|
|
161
|
+
const response = await PUT(
|
|
162
|
+
putRequest({ aiAutoReplyEnabled: true, aiAgentId: PROVIDER_AGENT.id }),
|
|
163
|
+
context,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
expect(response.status).toBe(200)
|
|
167
|
+
expect(commandBusExecuteMock).toHaveBeenCalledTimes(1)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('arms a foreign agent once the channel-bot user carries its features', async () => {
|
|
171
|
+
resolveDiscordAiPrincipalMock.mockResolvedValue({
|
|
172
|
+
tenantId,
|
|
173
|
+
organizationId,
|
|
174
|
+
userId,
|
|
175
|
+
features: ['channel_discord.ai_auto_reply.run', 'customers.view', 'customers.manage'],
|
|
176
|
+
isSuperAdmin: false,
|
|
177
|
+
source: 'channel_bot_user',
|
|
178
|
+
})
|
|
179
|
+
commandBusExecuteMock.mockResolvedValue({
|
|
180
|
+
result: {
|
|
181
|
+
status: 'updated',
|
|
182
|
+
channelId,
|
|
183
|
+
aiAutoReplyEnabled: true,
|
|
184
|
+
aiAgentId: FOREIGN_AGENT.id,
|
|
185
|
+
updatedAt: '2026-08-02T00:00:00.000Z',
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const response = await PUT(
|
|
190
|
+
putRequest({ aiAutoReplyEnabled: true, aiAgentId: FOREIGN_AGENT.id }),
|
|
191
|
+
context,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
expect(response.status).toBe(200)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('accepts a wildcard role grant, as the runtime would', async () => {
|
|
198
|
+
resolveDiscordAiPrincipalMock.mockResolvedValue({
|
|
199
|
+
tenantId,
|
|
200
|
+
organizationId,
|
|
201
|
+
userId,
|
|
202
|
+
features: ['channel_discord.ai_auto_reply.run', 'customers.*'],
|
|
203
|
+
isSuperAdmin: false,
|
|
204
|
+
source: 'channel_bot_user',
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const response = await PUT(
|
|
208
|
+
putRequest({ aiAutoReplyEnabled: true, aiAgentId: FOREIGN_AGENT.id }),
|
|
209
|
+
context,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
expect(response.status).toBe(200)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('never resolves a principal for a disarm, which needs no agent at all', async () => {
|
|
216
|
+
commandBusExecuteMock.mockResolvedValue({
|
|
217
|
+
result: {
|
|
218
|
+
status: 'updated',
|
|
219
|
+
channelId,
|
|
220
|
+
aiAutoReplyEnabled: false,
|
|
221
|
+
aiAgentId: null,
|
|
222
|
+
updatedAt: '2026-08-02T00:00:00.000Z',
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const response = await PUT(putRequest({ aiAutoReplyEnabled: false }), context)
|
|
227
|
+
|
|
228
|
+
expect(response.status).toBe(200)
|
|
229
|
+
expect(resolveDiscordAiPrincipalMock).not.toHaveBeenCalled()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('masks an unauthorized channel before it discloses anything about agents', async () => {
|
|
233
|
+
const notFound = new Response(JSON.stringify({ error: 'Channel not found' }), { status: 404 })
|
|
234
|
+
loadDiscordChannelForRequestMock.mockResolvedValue({ response: notFound })
|
|
235
|
+
|
|
236
|
+
const response = await PUT(
|
|
237
|
+
putRequest({ aiAutoReplyEnabled: true, aiAgentId: FOREIGN_AGENT.id }),
|
|
238
|
+
context,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
expect(response.status).toBe(404)
|
|
242
|
+
expect(listDiscordEligibleAgentsMock).not.toHaveBeenCalled()
|
|
243
|
+
expect(resolveDiscordAiPrincipalMock).not.toHaveBeenCalled()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('still reports an absent AI peer rather than a feature problem', async () => {
|
|
247
|
+
listDiscordEligibleAgentsMock.mockResolvedValue({ available: false })
|
|
248
|
+
|
|
249
|
+
const response = await PUT(
|
|
250
|
+
putRequest({ aiAutoReplyEnabled: true, aiAgentId: PROVIDER_AGENT.id }),
|
|
251
|
+
context,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
expect(response.status).toBe(400)
|
|
255
|
+
const body = await response.json()
|
|
256
|
+
expect(body.fieldErrors.aiAutoReplyEnabled).toBe('channel_discord.aiAutoReply.errors.aiUnavailable')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('still reports a non-object-mode agent as ineligible on shape', async () => {
|
|
260
|
+
listDiscordEligibleAgentsMock.mockResolvedValue({ available: true, agents: [PROVIDER_AGENT] })
|
|
261
|
+
|
|
262
|
+
const response = await PUT(
|
|
263
|
+
putRequest({ aiAutoReplyEnabled: true, aiAgentId: 'some.chat.agent' }),
|
|
264
|
+
context,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
expect(response.status).toBe(400)
|
|
268
|
+
const body = await response.json()
|
|
269
|
+
expect(body.fieldErrors.aiAgentId).toBe('channel_discord.aiAutoReply.errors.agentNotEligible')
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('GET /channel_discord/channels/{id}/ai-auto-reply — picker data', () => {
|
|
274
|
+
it('marks each offered agent with whether the principal could invoke it', async () => {
|
|
275
|
+
const response = await GET(getRequest(), context)
|
|
276
|
+
|
|
277
|
+
expect(response.status).toBe(200)
|
|
278
|
+
const body = await response.json()
|
|
279
|
+
expect(body.agents).toEqual([
|
|
280
|
+
expect.objectContaining({ id: PROVIDER_AGENT.id, invocable: true, missingFeatures: [] }),
|
|
281
|
+
expect.objectContaining({
|
|
282
|
+
id: FOREIGN_AGENT.id,
|
|
283
|
+
invocable: false,
|
|
284
|
+
missingFeatures: ['customers.view', 'customers.manage'],
|
|
285
|
+
}),
|
|
286
|
+
])
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('surfaces the failure marker an armed channel is carrying', async () => {
|
|
290
|
+
loadDiscordChannelForRequestMock.mockResolvedValue({
|
|
291
|
+
channel: {
|
|
292
|
+
...channel,
|
|
293
|
+
channelState: {
|
|
294
|
+
aiAutoReplyEnabled: true,
|
|
295
|
+
aiAgentId: FOREIGN_AGENT.id,
|
|
296
|
+
aiAutoReplyLastError: 'agent customers.support: agent_features_denied',
|
|
297
|
+
aiAutoReplyLastErrorAt: '2026-08-03T10:00:00.000Z',
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
organizationIds: [organizationId],
|
|
301
|
+
rbacOrganizationId: organizationId,
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
const response = await GET(getRequest(), context)
|
|
305
|
+
const body = await response.json()
|
|
306
|
+
|
|
307
|
+
expect(body.aiAutoReplyEnabled).toBe(true)
|
|
308
|
+
expect(body.aiAutoReplyLastError).toBe('agent customers.support: agent_features_denied')
|
|
309
|
+
expect(body.aiAutoReplyLastErrorAt).toBe('2026-08-03T10:00:00.000Z')
|
|
310
|
+
})
|
|
311
|
+
})
|