@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,299 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { AwilixContainer } from 'awilix'
|
|
4
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
5
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
6
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
|
+
import type { CommandBus } from '@open-mercato/shared/lib/commands'
|
|
8
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
9
|
+
import { validateRouteMutationGuard } from '@open-mercato/core/modules/communication_channels/lib/route-mutation-guard'
|
|
10
|
+
import { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from '../../../../ai-agents'
|
|
11
|
+
import {
|
|
12
|
+
CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,
|
|
13
|
+
CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,
|
|
14
|
+
discordAiAutoReplySettingsSchema,
|
|
15
|
+
type UpdateAiAutoReplyInput,
|
|
16
|
+
type UpdateAiAutoReplyResult,
|
|
17
|
+
} from '../../../../commands/update-ai-auto-reply'
|
|
18
|
+
import {
|
|
19
|
+
findDiscordEligibleAgent,
|
|
20
|
+
listDiscordEligibleAgents,
|
|
21
|
+
missingAgentFeatures,
|
|
22
|
+
type AgentInvokingPrincipal,
|
|
23
|
+
} from '../../../../lib/ai-agent-directory'
|
|
24
|
+
import { CHANNEL_DISCORD_CONFIGURE_FEATURE, CHANNEL_DISCORD_VIEW_FEATURE } from '../../../../lib/ai-features'
|
|
25
|
+
import { resolveDiscordAiPrincipal } from '../../../../lib/ai-service-principal'
|
|
26
|
+
import { loadDiscordChannelForRequest } from '../../../../lib/channel-access'
|
|
27
|
+
import { discordChannelStateSchema } from '../../../../lib/credentials'
|
|
28
|
+
|
|
29
|
+
export const metadata = {
|
|
30
|
+
GET: {
|
|
31
|
+
requireAuth: true,
|
|
32
|
+
requireFeatures: [CHANNEL_DISCORD_VIEW_FEATURE],
|
|
33
|
+
},
|
|
34
|
+
PUT: {
|
|
35
|
+
requireAuth: true,
|
|
36
|
+
requireFeatures: [CHANNEL_DISCORD_CONFIGURE_FEATURE],
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type RouteContext = {
|
|
41
|
+
params: Promise<{ id: string }> | { id: string }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fieldError(field: string, message: string, extra?: Record<string, unknown>): Response {
|
|
45
|
+
return NextResponse.json(
|
|
46
|
+
{ error: message, fieldErrors: { [field]: message }, ...extra },
|
|
47
|
+
{ status: 400 },
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The identity the auto-reply subscriber will run the chosen agent under, so both
|
|
53
|
+
* methods can answer "would the runtime accept this?" with the same grants the
|
|
54
|
+
* subscriber will present — the tenant's channel-bot user when it exists, the
|
|
55
|
+
* provider service principal otherwise.
|
|
56
|
+
*/
|
|
57
|
+
async function loadInvokingPrincipal(
|
|
58
|
+
container: AwilixContainer,
|
|
59
|
+
scope: { tenantId: string; organizationId: string | null },
|
|
60
|
+
): Promise<AgentInvokingPrincipal> {
|
|
61
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
62
|
+
return resolveDiscordAiPrincipal({
|
|
63
|
+
em,
|
|
64
|
+
resolver: { resolve: <T,>(name: string): T => container.resolve(name) as T },
|
|
65
|
+
scope,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read a Discord channel's AI auto-reply settings plus everything the settings
|
|
71
|
+
* form needs to render itself: the eligible agents, whether the optional AI peer
|
|
72
|
+
* is installed at all, and the channel's `updatedAt` so the form can attach the
|
|
73
|
+
* optimistic-lock header on save.
|
|
74
|
+
*/
|
|
75
|
+
export async function GET(req: Request, context: RouteContext): Promise<Response> {
|
|
76
|
+
const { id } = await context.params
|
|
77
|
+
if (!z.string().uuid().safeParse(id).success) {
|
|
78
|
+
return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const auth = await getAuthFromRequest(req)
|
|
82
|
+
if (!auth?.sub || !auth?.tenantId) {
|
|
83
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const container = await createRequestContainer()
|
|
87
|
+
const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'read' })
|
|
88
|
+
if ('response' in access) return access.response
|
|
89
|
+
const { channel } = access
|
|
90
|
+
|
|
91
|
+
const state = discordChannelStateSchema.safeParse(channel.channelState ?? {})
|
|
92
|
+
const directory = await listDiscordEligibleAgents()
|
|
93
|
+
|
|
94
|
+
// Every offered agent carries whether the auto-reply principal could actually
|
|
95
|
+
// invoke it. Without this the picker offers agents from other modules that the
|
|
96
|
+
// runtime will refuse, and the refusal only ever happens later in a background
|
|
97
|
+
// subscriber — so the operator sees "Auto-reply on" and silence.
|
|
98
|
+
const principal = directory.available
|
|
99
|
+
? await loadInvokingPrincipal(container, {
|
|
100
|
+
tenantId: auth.tenantId as string,
|
|
101
|
+
organizationId: access.rbacOrganizationId,
|
|
102
|
+
})
|
|
103
|
+
: null
|
|
104
|
+
const agents = directory.available && principal
|
|
105
|
+
? directory.agents.map((agent) => {
|
|
106
|
+
const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)
|
|
107
|
+
return { ...agent, invocable: missingFeatures.length === 0, missingFeatures }
|
|
108
|
+
})
|
|
109
|
+
: []
|
|
110
|
+
|
|
111
|
+
return NextResponse.json({
|
|
112
|
+
id: channel.id,
|
|
113
|
+
channelId: channel.id,
|
|
114
|
+
displayName: channel.displayName,
|
|
115
|
+
updatedAt: channel.updatedAt ? channel.updatedAt.toISOString() : null,
|
|
116
|
+
aiAutoReplyEnabled: state.success ? Boolean(state.data.aiAutoReplyEnabled) : false,
|
|
117
|
+
aiAgentId: (state.success ? state.data.aiAgentId : undefined) ?? null,
|
|
118
|
+
// Why the last attempt produced nothing, so an armed channel that answers
|
|
119
|
+
// nothing says so instead of looking healthy.
|
|
120
|
+
aiAutoReplyLastError: (state.success ? state.data.aiAutoReplyLastError : undefined) ?? null,
|
|
121
|
+
aiAutoReplyLastErrorAt: (state.success ? state.data.aiAutoReplyLastErrorAt : undefined) ?? null,
|
|
122
|
+
defaultAgentId: CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID,
|
|
123
|
+
aiAvailable: directory.available,
|
|
124
|
+
agents,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Write a Discord channel's AI auto-reply settings — the configuration path the
|
|
130
|
+
* subscriber was missing (issue #4778).
|
|
131
|
+
*
|
|
132
|
+
* The route is deliberately strict about *enabling*: it refuses to arm a channel
|
|
133
|
+
* whose AI peer is absent, one pointed at an agent the runtime would reject on
|
|
134
|
+
* shape, and one pointed at an agent the auto-reply principal is not authorized
|
|
135
|
+
* to invoke. All three would store a setting that can only fail later, inside a
|
|
136
|
+
* background subscriber where nobody sees the error — which is precisely how the
|
|
137
|
+
* feature ended up dormant in the first place. The three checks mirror, in order,
|
|
138
|
+
* what `runAiAgentObject` → `checkAgentPolicy` will ask at run time.
|
|
139
|
+
*/
|
|
140
|
+
export async function PUT(req: Request, context: RouteContext): Promise<Response> {
|
|
141
|
+
const { id } = await context.params
|
|
142
|
+
if (!z.string().uuid().safeParse(id).success) {
|
|
143
|
+
return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const auth = await getAuthFromRequest(req)
|
|
147
|
+
if (!auth?.sub || !auth?.tenantId) {
|
|
148
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
149
|
+
}
|
|
150
|
+
const organizationId = (auth as { orgId?: string | null }).orgId ?? null
|
|
151
|
+
|
|
152
|
+
const rawBody = await req.json().catch(() => null)
|
|
153
|
+
if (rawBody == null || typeof rawBody !== 'object') {
|
|
154
|
+
return NextResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const parsed = discordAiAutoReplySettingsSchema.safeParse(rawBody)
|
|
158
|
+
if (!parsed.success) {
|
|
159
|
+
const issue = parsed.error.issues[0]
|
|
160
|
+
const field = issue?.path.join('.') || 'aiAutoReplyEnabled'
|
|
161
|
+
return fieldError(field, issue?.message ?? 'Invalid AI auto-reply settings')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const container = await createRequestContainer()
|
|
165
|
+
// Authorize before the guard so a caller who may not touch this channel gets a
|
|
166
|
+
// masked 404 rather than a guard verdict about a record they cannot see. It
|
|
167
|
+
// also comes before the arming checks below, which need the channel's resolved
|
|
168
|
+
// organization to build the principal they judge the agent against.
|
|
169
|
+
const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'manage' })
|
|
170
|
+
if ('response' in access) return access.response
|
|
171
|
+
|
|
172
|
+
if (parsed.data.aiAutoReplyEnabled) {
|
|
173
|
+
const agentId = parsed.data.aiAgentId as string
|
|
174
|
+
const agent = await findDiscordEligibleAgent(agentId)
|
|
175
|
+
if (!agent) {
|
|
176
|
+
// No eligible agent under this id: either the optional AI peer is absent, or
|
|
177
|
+
// the agent is not object-mode and `runAiAgentObject` would reject it.
|
|
178
|
+
const directory = await listDiscordEligibleAgents()
|
|
179
|
+
return directory.available
|
|
180
|
+
? fieldError('aiAgentId', 'channel_discord.aiAutoReply.errors.agentNotEligible')
|
|
181
|
+
: fieldError('aiAutoReplyEnabled', 'channel_discord.aiAutoReply.errors.aiUnavailable')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Shape is not authorization. `checkAgentPolicy` enforces the agent's
|
|
185
|
+
// `requiredFeatures` against the auto-reply principal at run time, so arming a
|
|
186
|
+
// channel against an agent that principal cannot invoke stores a setting that
|
|
187
|
+
// can only fail later — inside a background subscriber, where the operator
|
|
188
|
+
// never sees it. Refuse here, naming the grants the tenant's channel-bot user
|
|
189
|
+
// is missing, rather than accepting a channel that would answer nothing.
|
|
190
|
+
const principal = await loadInvokingPrincipal(container, {
|
|
191
|
+
tenantId: auth.tenantId as string,
|
|
192
|
+
organizationId: access.rbacOrganizationId,
|
|
193
|
+
})
|
|
194
|
+
const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)
|
|
195
|
+
if (missingFeatures.length > 0) {
|
|
196
|
+
return fieldError(
|
|
197
|
+
'aiAgentId',
|
|
198
|
+
'channel_discord.aiAutoReply.errors.agentFeaturesMissing',
|
|
199
|
+
{ missingFeatures },
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const guard = await validateRouteMutationGuard({
|
|
205
|
+
container,
|
|
206
|
+
req,
|
|
207
|
+
auth,
|
|
208
|
+
input: {
|
|
209
|
+
resourceKind: CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,
|
|
210
|
+
resourceId: id,
|
|
211
|
+
operation: 'update',
|
|
212
|
+
mutationPayload: { ...parsed.data },
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
if ('response' in guard) return guard.response
|
|
216
|
+
|
|
217
|
+
const commandBus = container.resolve('commandBus') as CommandBus
|
|
218
|
+
const input: UpdateAiAutoReplyInput = {
|
|
219
|
+
channelId: id,
|
|
220
|
+
settings: parsed.data,
|
|
221
|
+
scope: {
|
|
222
|
+
tenantId: auth.tenantId as string,
|
|
223
|
+
organizationId: access.rbacOrganizationId,
|
|
224
|
+
organizationIds: access.organizationIds,
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let result: UpdateAiAutoReplyResult
|
|
229
|
+
try {
|
|
230
|
+
const executed = await commandBus.execute<UpdateAiAutoReplyInput, UpdateAiAutoReplyResult>(
|
|
231
|
+
CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,
|
|
232
|
+
{
|
|
233
|
+
input,
|
|
234
|
+
ctx: {
|
|
235
|
+
container,
|
|
236
|
+
auth: auth as never,
|
|
237
|
+
organizationScope: null,
|
|
238
|
+
selectedOrganizationId: organizationId,
|
|
239
|
+
organizationIds: organizationId ? [organizationId] : null,
|
|
240
|
+
request: req,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
result = executed.result
|
|
245
|
+
} catch (err) {
|
|
246
|
+
// The optimistic-lock guard throws the shared structured 409 so the form's
|
|
247
|
+
// conflict bar renders the same way it does anywhere else in the product.
|
|
248
|
+
if (isCrudHttpError(err)) {
|
|
249
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
250
|
+
}
|
|
251
|
+
throw err
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (result.status === 'not_found') {
|
|
255
|
+
return NextResponse.json({ error: 'Channel not found' }, { status: 404 })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await guard.afterSuccess()
|
|
259
|
+
return NextResponse.json(
|
|
260
|
+
{
|
|
261
|
+
channelId: result.channelId,
|
|
262
|
+
aiAutoReplyEnabled: result.aiAutoReplyEnabled,
|
|
263
|
+
aiAgentId: result.aiAgentId,
|
|
264
|
+
updatedAt: result.updatedAt,
|
|
265
|
+
},
|
|
266
|
+
{ status: 200 },
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const openApi = {
|
|
271
|
+
tags: ['CommunicationChannels'],
|
|
272
|
+
methods: {
|
|
273
|
+
GET: {
|
|
274
|
+
summary: "Read a Discord channel's AI auto-reply configuration",
|
|
275
|
+
tags: ['CommunicationChannels'],
|
|
276
|
+
responses: [
|
|
277
|
+
{ status: 200, description: 'Current settings plus the agents this channel may be pointed at' },
|
|
278
|
+
{ status: 400, description: 'Invalid channel id' },
|
|
279
|
+
{ status: 401, description: 'Unauthorized' },
|
|
280
|
+
{ status: 404, description: 'Channel not found, not a Discord channel in scope, or not the caller’s to see' },
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
PUT: {
|
|
284
|
+
summary: "Update a Discord channel's AI auto-reply configuration",
|
|
285
|
+
tags: ['CommunicationChannels'],
|
|
286
|
+
responses: [
|
|
287
|
+
{ status: 200, description: 'Settings stored' },
|
|
288
|
+
{
|
|
289
|
+
status: 400,
|
|
290
|
+
description:
|
|
291
|
+
'Invalid payload, AI module absent, agent not eligible, or the auto-reply principal lacks the agent’s required features',
|
|
292
|
+
},
|
|
293
|
+
{ status: 401, description: 'Unauthorized' },
|
|
294
|
+
{ status: 404, description: 'Channel not found, or not a Discord channel in this scope' },
|
|
295
|
+
{ status: 409, description: 'The channel changed since the form was loaded' },
|
|
296
|
+
],
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
6
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
7
|
+
import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
|
|
8
|
+
import { discordCredentialsSchema } from '../../lib/credentials'
|
|
9
|
+
import { DISCORD_MESSAGE_FLAG_EPHEMERAL } from '../../lib/discord-rest'
|
|
10
|
+
import type { DispatchableInteraction } from '../../lib/interactions-dispatch'
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_INTERACTION_MESSAGES,
|
|
13
|
+
resolveDiscordInteraction,
|
|
14
|
+
screenInteractionRequest,
|
|
15
|
+
type InteractionCandidate,
|
|
16
|
+
type InteractionCandidateFilter,
|
|
17
|
+
} from '../../lib/interactions-handler'
|
|
18
|
+
import {
|
|
19
|
+
getInteractionDispatchQueue,
|
|
20
|
+
type InteractionDispatchJobPayload,
|
|
21
|
+
} from '../../lib/interactions-queue'
|
|
22
|
+
import { DiscordInteractionResponseType } from '../../lib/interactions-verify'
|
|
23
|
+
|
|
24
|
+
const logger = createLogger('channel_discord').child({ component: 'interactions-route' })
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Discord Interactions endpoint (slash commands, buttons, PING handshake).
|
|
28
|
+
*
|
|
29
|
+
* This is a **provider-owned** signed route — the resolution to the spec's one
|
|
30
|
+
* "under negotiation" hub touch-point. Discord requires a *synchronous* PONG
|
|
31
|
+
* (`{ type: 1 }`) on the initial PING, which the hub's generic
|
|
32
|
+
* `api/post/webhook/[provider]` route cannot return (it 202-acks + enqueues). By
|
|
33
|
+
* shipping this route from the provider package we serve the handshake without
|
|
34
|
+
* changing the hub contract. Operators set the Interactions Endpoint URL to
|
|
35
|
+
* `/api/channel_discord/interactions`.
|
|
36
|
+
*
|
|
37
|
+
* Auth model: unauthenticated at the platform layer — Ed25519 signature
|
|
38
|
+
* verification IS the auth, and it is fail-closed (a tampered/missing signature
|
|
39
|
+
* verifies against no candidate channel → 401).
|
|
40
|
+
*
|
|
41
|
+
* Timing model: Discord closes an interaction that is not answered within three
|
|
42
|
+
* seconds, so a verified slash command / component press is answered with a
|
|
43
|
+
* DEFERRED acknowledgement and handed to `workers/discord-interactions.ts`,
|
|
44
|
+
* which does the two slow halves — writing the interaction into the hub's
|
|
45
|
+
* inbound queue and replacing the "thinking…" placeholder with a real message.
|
|
46
|
+
* The hand-off happens BEFORE the ack is returned, so a queue that cannot accept
|
|
47
|
+
* the job downgrades the response to a visible error instead of promising a
|
|
48
|
+
* follow-up nothing will send.
|
|
49
|
+
*/
|
|
50
|
+
export const metadata = {
|
|
51
|
+
// Pinned explicitly: this URL is operator-facing — it goes into the Discord
|
|
52
|
+
// application's Interactions Endpoint field — so it is part of this route's
|
|
53
|
+
// contract rather than a by-product of where the file happens to sit. It is
|
|
54
|
+
// the same path the folder derives, so the pin documents the contract instead
|
|
55
|
+
// of overriding it.
|
|
56
|
+
path: '/channel_discord/interactions',
|
|
57
|
+
POST: {
|
|
58
|
+
requireAuth: false,
|
|
59
|
+
// Unauthenticated by design. Unsigned traffic is now rejected before any
|
|
60
|
+
// candidate is loaded, so this bounds the residual case: a caller who does
|
|
61
|
+
// present well-formed, fresh headers and drives the narrowed candidate load
|
|
62
|
+
// repeatedly before the signature gate rejects them.
|
|
63
|
+
rateLimit: { points: 120, duration: 60, keyPrefix: 'discord_interactions' },
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function POST(req: Request): Promise<Response> {
|
|
68
|
+
const rawBody = await req.text()
|
|
69
|
+
const signatureHex = req.headers.get('x-signature-ed25519')
|
|
70
|
+
const timestamp = req.headers.get('x-signature-timestamp')
|
|
71
|
+
|
|
72
|
+
// Screened here as well as inside `resolveDiscordInteraction` so that unsigned,
|
|
73
|
+
// malformed or stale traffic returns before the dictionary load below — this
|
|
74
|
+
// route is unauthenticated, so everything decidable from the request alone
|
|
75
|
+
// stays ahead of every other cost.
|
|
76
|
+
const screened = screenInteractionRequest({ signatureHex, timestamp })
|
|
77
|
+
if (screened) return NextResponse.json(screened.body, { status: screened.status })
|
|
78
|
+
|
|
79
|
+
const { t } = await resolveTranslations()
|
|
80
|
+
|
|
81
|
+
const result = await resolveDiscordInteraction({
|
|
82
|
+
rawBody,
|
|
83
|
+
signatureHex,
|
|
84
|
+
timestamp,
|
|
85
|
+
loadCandidates: loadInteractionCandidates,
|
|
86
|
+
messages: {
|
|
87
|
+
notDispatchable: t(
|
|
88
|
+
'channel_discord.interactions.notDispatchable',
|
|
89
|
+
DEFAULT_INTERACTION_MESSAGES.notDispatchable,
|
|
90
|
+
),
|
|
91
|
+
unsupported: t('channel_discord.interactions.unsupported', DEFAULT_INTERACTION_MESSAGES.unsupported),
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// A deferred acknowledgement is a promise of a follow-up. Enqueue it BEFORE
|
|
96
|
+
// answering: if the hand-off fails the user gets a visible error instead of a
|
|
97
|
+
// "thinking…" state nothing will ever replace.
|
|
98
|
+
if (result.dispatch && result.matchedChannel) {
|
|
99
|
+
const enqueued = await enqueueInteractionDispatch(result.dispatch, result.matchedChannel)
|
|
100
|
+
if (!enqueued) {
|
|
101
|
+
return NextResponse.json(
|
|
102
|
+
{
|
|
103
|
+
type: DiscordInteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
|
|
104
|
+
data: {
|
|
105
|
+
content: t(
|
|
106
|
+
'channel_discord.interactions.dispatchFailed',
|
|
107
|
+
'Open Mercato could not record this interaction right now. Please try again in a moment.',
|
|
108
|
+
),
|
|
109
|
+
flags: DISCORD_MESSAGE_FLAG_EPHEMERAL,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{ status: 200 },
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return NextResponse.json(result.body, { status: result.status })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Hand the verified interaction to the dispatch worker. Returns `false` when the
|
|
122
|
+
* job could not be queued at all, which is the one case where the deferred ack
|
|
123
|
+
* must be downgraded to a visible reply.
|
|
124
|
+
*/
|
|
125
|
+
async function enqueueInteractionDispatch(
|
|
126
|
+
dispatch: DispatchableInteraction,
|
|
127
|
+
channel: InteractionCandidate,
|
|
128
|
+
): Promise<boolean> {
|
|
129
|
+
const payload: InteractionDispatchJobPayload = {
|
|
130
|
+
channelId: channel.channelId,
|
|
131
|
+
channelType: channel.channelType,
|
|
132
|
+
tenantId: channel.tenantId,
|
|
133
|
+
organizationId: channel.organizationId,
|
|
134
|
+
credentialScope: channel.credentialScope,
|
|
135
|
+
interaction: dispatch,
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
await getInteractionDispatchQueue().enqueue(payload as unknown as Record<string, unknown>)
|
|
139
|
+
return true
|
|
140
|
+
} catch (err) {
|
|
141
|
+
logger.error('failed to enqueue discord interaction dispatch', { err, channelId: channel.channelId })
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type CredentialsServiceLike = {
|
|
147
|
+
resolve: (
|
|
148
|
+
integrationId: string,
|
|
149
|
+
scope: { organizationId: string; tenantId: string; userId?: string | null },
|
|
150
|
+
) => Promise<Record<string, unknown> | null>
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function loadInteractionCandidates(
|
|
154
|
+
filter: InteractionCandidateFilter,
|
|
155
|
+
): Promise<InteractionCandidate[]> {
|
|
156
|
+
const container = await createRequestContainer()
|
|
157
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
158
|
+
|
|
159
|
+
let credentialsService: CredentialsServiceLike | null = null
|
|
160
|
+
try {
|
|
161
|
+
credentialsService = container.resolve<CredentialsServiceLike>('integrationCredentialsService')
|
|
162
|
+
} catch {
|
|
163
|
+
credentialsService = null
|
|
164
|
+
}
|
|
165
|
+
if (!credentialsService) return []
|
|
166
|
+
|
|
167
|
+
const candidates: InteractionCandidate[] = []
|
|
168
|
+
try {
|
|
169
|
+
const rows = (await findWithDecryption(em, CommunicationChannel, {
|
|
170
|
+
providerKey: 'discord',
|
|
171
|
+
isActive: true,
|
|
172
|
+
deletedAt: null,
|
|
173
|
+
})) as CommunicationChannel[]
|
|
174
|
+
|
|
175
|
+
// Credentials are resolved per (tenant, organization, user) scope, and a
|
|
176
|
+
// tenant's Discord channels usually share one — cache within this request so
|
|
177
|
+
// N channel rows do not become N decrypts of the same credential bag.
|
|
178
|
+
const resolvedByScope = new Map<string, Record<string, unknown> | null>()
|
|
179
|
+
|
|
180
|
+
for (const channel of rows) {
|
|
181
|
+
if (!channel.credentialsRef) continue
|
|
182
|
+
const organizationId = channel.organizationId ?? channel.tenantId
|
|
183
|
+
const userId = channel.userId ?? null
|
|
184
|
+
const scopeKey = `${channel.tenantId}|${organizationId}|${userId ?? ''}`
|
|
185
|
+
if (!resolvedByScope.has(scopeKey)) {
|
|
186
|
+
try {
|
|
187
|
+
resolvedByScope.set(
|
|
188
|
+
scopeKey,
|
|
189
|
+
await credentialsService.resolve('channel_discord', {
|
|
190
|
+
tenantId: channel.tenantId,
|
|
191
|
+
organizationId,
|
|
192
|
+
userId,
|
|
193
|
+
}),
|
|
194
|
+
)
|
|
195
|
+
} catch {
|
|
196
|
+
resolvedByScope.set(scopeKey, null)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const parsed = discordCredentialsSchema.safeParse(resolvedByScope.get(scopeKey) ?? {})
|
|
200
|
+
if (!parsed.success) continue
|
|
201
|
+
// Narrowing only — the signature still decides. A body claiming an
|
|
202
|
+
// application nobody here owns simply verifies against nothing.
|
|
203
|
+
if (filter.applicationId && parsed.data.applicationId !== filter.applicationId) continue
|
|
204
|
+
candidates.push({
|
|
205
|
+
channelId: channel.id,
|
|
206
|
+
channelType: channel.channelType,
|
|
207
|
+
tenantId: channel.tenantId,
|
|
208
|
+
organizationId: channel.organizationId ?? null,
|
|
209
|
+
publicKey: parsed.data.publicKey,
|
|
210
|
+
applicationId: parsed.data.applicationId,
|
|
211
|
+
// The exact scope this row's credentials resolved under, so the worker
|
|
212
|
+
// re-resolves the same bag instead of guessing — and no token travels.
|
|
213
|
+
credentialScope: { tenantId: channel.tenantId, organizationId, userId },
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
logger.warn('failed to load discord interaction candidates', { err })
|
|
218
|
+
return []
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return candidates
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const openApi = {
|
|
225
|
+
tags: ['ChannelDiscord'],
|
|
226
|
+
summary: 'Discord Interactions endpoint (slash commands, buttons, PING handshake)',
|
|
227
|
+
methods: {
|
|
228
|
+
POST: {
|
|
229
|
+
summary: 'Verify (Ed25519, fail-closed) and dispatch a Discord interaction',
|
|
230
|
+
tags: ['ChannelDiscord'],
|
|
231
|
+
responses: [
|
|
232
|
+
{
|
|
233
|
+
status: 200,
|
|
234
|
+
description:
|
|
235
|
+
'Verified interaction — PONG for the handshake, a deferred ack for a dispatched slash command / component / modal submission, an empty autocomplete result, or an ephemeral message when the interaction cannot be dispatched',
|
|
236
|
+
},
|
|
237
|
+
{ status: 400, description: 'Verified but malformed interaction body' },
|
|
238
|
+
{ status: 401, description: 'Signature verification failed against every candidate channel, or the signed timestamp is outside the replay window' },
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
}
|
package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PageMetadata } from '@open-mercato/shared/modules/registry'
|
|
2
|
+
import { CHANNEL_DISCORD_CONFIGURE_FEATURE } from '../../../../../lib/ai-features'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Settings surface for one Discord channel's AI auto-reply. Guarded by the same
|
|
6
|
+
* feature that gates connecting the bot — arming an agent to answer strangers on
|
|
7
|
+
* the tenant's behalf is a configuration act, not a viewing one — and kept out of
|
|
8
|
+
* the sidebar, because it is reached from the channel it configures.
|
|
9
|
+
*/
|
|
10
|
+
export const metadata: PageMetadata = {
|
|
11
|
+
requireAuth: true,
|
|
12
|
+
requireFeatures: [CHANNEL_DISCORD_CONFIGURE_FEATURE],
|
|
13
|
+
titleKey: 'channel_discord.aiAutoReply.page.title',
|
|
14
|
+
title: 'Discord AI auto-reply',
|
|
15
|
+
navHidden: true,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default metadata
|