@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,288 @@
|
|
|
1
|
+
import type { InboundProcessorPayload } from '@open-mercato/core/modules/communication_channels/workers/inbound-processor'
|
|
2
|
+
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
3
|
+
import { DISCORD_MAX_BODY_LENGTH } from './capabilities'
|
|
4
|
+
import type { DiscordMessageObject, DiscordRestClient, DiscordUser } from './discord-rest'
|
|
5
|
+
import { buildInboundMessageJob, type GatewayChannelScope } from './gateway-bridge'
|
|
6
|
+
import { DiscordInteractionType, type ParsedInteraction } from './interactions-verify'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Everything the dispatch path needs from a verified interaction, extracted once
|
|
10
|
+
* so nothing downstream has to re-read the untrusted body.
|
|
11
|
+
*
|
|
12
|
+
* This shape crosses a queue boundary (the HTTP route must answer Discord within
|
|
13
|
+
* three seconds, so the hub write and the follow-up REST call happen in a
|
|
14
|
+
* worker), which is why it carries no credential: `token` is the interaction's
|
|
15
|
+
* own short-lived webhook credential and the bot token is re-resolved from the
|
|
16
|
+
* encrypted credential store on the other side.
|
|
17
|
+
*/
|
|
18
|
+
export interface DispatchableInteraction {
|
|
19
|
+
/** Interaction snowflake — also the hub's `externalMessageId`, so redelivery dedups. */
|
|
20
|
+
id: string
|
|
21
|
+
/** Interaction webhook token. Valid for 15 minutes, single interaction. */
|
|
22
|
+
token: string
|
|
23
|
+
type: number
|
|
24
|
+
applicationId: string
|
|
25
|
+
/** Discord text channel the interaction happened in. */
|
|
26
|
+
discordChannelId: string
|
|
27
|
+
guildId?: string
|
|
28
|
+
user: DiscordUser
|
|
29
|
+
/** Slash-command name, for `APPLICATION_COMMAND` / autocomplete. */
|
|
30
|
+
commandName?: string
|
|
31
|
+
/** Component / modal `custom_id`, for `MESSAGE_COMPONENT` / `MODAL_SUBMIT`. */
|
|
32
|
+
customId?: string
|
|
33
|
+
/** Human-readable rendering of what the user did — the hub message body. */
|
|
34
|
+
content: string
|
|
35
|
+
/** ISO timestamp the hub message is stamped with. */
|
|
36
|
+
timestamp: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The interaction types this provider turns into hub messages. */
|
|
40
|
+
export const DISPATCHABLE_INTERACTION_TYPES: readonly number[] = [
|
|
41
|
+
DiscordInteractionType.APPLICATION_COMMAND,
|
|
42
|
+
DiscordInteractionType.MESSAGE_COMPONENT,
|
|
43
|
+
DiscordInteractionType.MODAL_SUBMIT,
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
export function isDispatchableInteractionType(type: number): boolean {
|
|
47
|
+
return DISPATCHABLE_INTERACTION_TYPES.includes(type)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readString(source: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
51
|
+
const value = source?.[key]
|
|
52
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readOptionValue(option: Record<string, unknown>): string {
|
|
56
|
+
const value = option.value
|
|
57
|
+
if (value === undefined || value === null) return ''
|
|
58
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
59
|
+
return String(value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Flatten a slash command's options into `name:value` pairs.
|
|
64
|
+
*
|
|
65
|
+
* Discord nests subcommands and subcommand groups as options whose own `options`
|
|
66
|
+
* array holds the real arguments, so a naive one-level read renders
|
|
67
|
+
* `/support ticket:` and loses everything the user typed. Recursion is bounded by
|
|
68
|
+
* Discord's own two-level nesting limit.
|
|
69
|
+
*/
|
|
70
|
+
function renderCommandOptions(options: unknown): string[] {
|
|
71
|
+
if (!Array.isArray(options)) return []
|
|
72
|
+
const rendered: string[] = []
|
|
73
|
+
for (const entry of options) {
|
|
74
|
+
if (!entry || typeof entry !== 'object') continue
|
|
75
|
+
const option = entry as Record<string, unknown>
|
|
76
|
+
const name = readString(option, 'name')
|
|
77
|
+
if (!name) continue
|
|
78
|
+
const nested = renderCommandOptions(option.options)
|
|
79
|
+
if (nested.length > 0) {
|
|
80
|
+
rendered.push(name, ...nested)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
rendered.push(`${name}:${readOptionValue(option)}`)
|
|
84
|
+
}
|
|
85
|
+
return rendered
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Values a select menu returned, or the fields a modal submitted. */
|
|
89
|
+
function renderComponentValues(data: Record<string, unknown> | undefined): string[] {
|
|
90
|
+
if (!data) return []
|
|
91
|
+
const values = data.values
|
|
92
|
+
if (Array.isArray(values)) return values.map((value) => String(value))
|
|
93
|
+
|
|
94
|
+
// Modal submissions nest one input per action row.
|
|
95
|
+
const rows = data.components
|
|
96
|
+
if (!Array.isArray(rows)) return []
|
|
97
|
+
const rendered: string[] = []
|
|
98
|
+
for (const row of rows) {
|
|
99
|
+
const children = (row as { components?: unknown } | null)?.components
|
|
100
|
+
if (!Array.isArray(children)) continue
|
|
101
|
+
for (const child of children) {
|
|
102
|
+
if (!child || typeof child !== 'object') continue
|
|
103
|
+
const field = child as Record<string, unknown>
|
|
104
|
+
const customId = readString(field, 'custom_id')
|
|
105
|
+
const value = readString(field, 'value')
|
|
106
|
+
if (!customId) continue
|
|
107
|
+
rendered.push(`${customId}:${value ?? ''}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return rendered
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Render an interaction as the text a human reading the hub inbox needs.
|
|
115
|
+
*
|
|
116
|
+
* A slash command becomes `/support message:the printer is jammed`; a button or
|
|
117
|
+
* select becomes `[component] escalate` plus any selected values. The hub stores
|
|
118
|
+
* the full interaction object in `channelPayload`, so this is a readable summary
|
|
119
|
+
* rather than the record of truth.
|
|
120
|
+
*/
|
|
121
|
+
export function renderInteractionContent(interaction: ParsedInteraction): string {
|
|
122
|
+
const data = interaction.data
|
|
123
|
+
const commandName = readString(data, 'name')
|
|
124
|
+
const customId = readString(data, 'custom_id')
|
|
125
|
+
|
|
126
|
+
let rendered: string
|
|
127
|
+
if (interaction.type === DiscordInteractionType.APPLICATION_COMMAND) {
|
|
128
|
+
rendered = [`/${commandName ?? 'command'}`, ...renderCommandOptions(data?.options)].join(' ')
|
|
129
|
+
} else if (interaction.type === DiscordInteractionType.MESSAGE_COMPONENT) {
|
|
130
|
+
rendered = [`[component] ${customId ?? 'unknown'}`, ...renderComponentValues(data)].join(' ')
|
|
131
|
+
} else if (interaction.type === DiscordInteractionType.MODAL_SUBMIT) {
|
|
132
|
+
rendered = [`[modal] ${customId ?? 'unknown'}`, ...renderComponentValues(data)].join(' ')
|
|
133
|
+
} else {
|
|
134
|
+
rendered = `[interaction ${interaction.type}]`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return rendered.length > DISCORD_MAX_BODY_LENGTH ? rendered.slice(0, DISCORD_MAX_BODY_LENGTH) : rendered
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Narrow a verified interaction to the fields the dispatch needs, or `null` when
|
|
142
|
+
* the payload cannot be dispatched at all.
|
|
143
|
+
*
|
|
144
|
+
* Returning `null` is not a rejection of the *caller* — the signature already
|
|
145
|
+
* verified — it means Discord sent a shape this provider cannot turn into a hub
|
|
146
|
+
* message (no channel, no invoking user, no follow-up token). The route answers
|
|
147
|
+
* those with a visible message rather than a deferred ack it could never
|
|
148
|
+
* replace.
|
|
149
|
+
*/
|
|
150
|
+
export function readDispatchableInteraction(
|
|
151
|
+
interaction: ParsedInteraction,
|
|
152
|
+
options?: { now?: Date },
|
|
153
|
+
): DispatchableInteraction | null {
|
|
154
|
+
if (!isDispatchableInteractionType(interaction.type)) return null
|
|
155
|
+
|
|
156
|
+
const id = typeof interaction.id === 'string' && interaction.id.length > 0 ? interaction.id : null
|
|
157
|
+
const token = typeof interaction.token === 'string' && interaction.token.length > 0 ? interaction.token : null
|
|
158
|
+
const applicationId =
|
|
159
|
+
typeof interaction.application_id === 'string' && interaction.application_id.length > 0
|
|
160
|
+
? interaction.application_id
|
|
161
|
+
: null
|
|
162
|
+
const discordChannelId =
|
|
163
|
+
typeof interaction.channel_id === 'string' && interaction.channel_id.length > 0 ? interaction.channel_id : null
|
|
164
|
+
// A guild interaction carries the invoker under `member.user`; a DM carries it
|
|
165
|
+
// directly under `user`.
|
|
166
|
+
const rawUser = interaction.member?.user ?? interaction.user
|
|
167
|
+
const userId = typeof rawUser?.id === 'string' && rawUser.id.length > 0 ? rawUser.id : null
|
|
168
|
+
|
|
169
|
+
if (!id || !token || !applicationId || !discordChannelId || !userId) return null
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
id,
|
|
173
|
+
token,
|
|
174
|
+
type: interaction.type,
|
|
175
|
+
applicationId,
|
|
176
|
+
discordChannelId,
|
|
177
|
+
guildId: typeof interaction.guild_id === 'string' ? interaction.guild_id : undefined,
|
|
178
|
+
user: {
|
|
179
|
+
id: userId,
|
|
180
|
+
username: rawUser?.username ?? userId,
|
|
181
|
+
global_name: rawUser?.global_name ?? null,
|
|
182
|
+
},
|
|
183
|
+
commandName: readString(interaction.data, 'name'),
|
|
184
|
+
customId: readString(interaction.data, 'custom_id'),
|
|
185
|
+
content: renderInteractionContent(interaction),
|
|
186
|
+
timestamp: (options?.now ?? new Date()).toISOString(),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Present the interaction as the raw Discord message object the hub's inbound
|
|
192
|
+
* path already understands.
|
|
193
|
+
*
|
|
194
|
+
* WHY A SYNTHESIZED MESSAGE: the hub contract stays untouched. The inbound
|
|
195
|
+
* processor calls `adapter.normalizeInbound` on whatever `raw` it is handed, so
|
|
196
|
+
* shaping the interaction like a message means a slash command lands in the same
|
|
197
|
+
* conversation, under the same tenant scope, with the same dedup key discipline
|
|
198
|
+
* as anything typed in the channel — no hub-side special case for interactions.
|
|
199
|
+
*/
|
|
200
|
+
export function buildInteractionMessage(dispatch: DispatchableInteraction): DiscordMessageObject {
|
|
201
|
+
return {
|
|
202
|
+
id: dispatch.id,
|
|
203
|
+
channel_id: dispatch.discordChannelId,
|
|
204
|
+
guild_id: dispatch.guildId,
|
|
205
|
+
content: dispatch.content,
|
|
206
|
+
author: dispatch.user,
|
|
207
|
+
timestamp: dispatch.timestamp,
|
|
208
|
+
discord_interaction: {
|
|
209
|
+
id: dispatch.id,
|
|
210
|
+
type: dispatch.type,
|
|
211
|
+
applicationId: dispatch.applicationId,
|
|
212
|
+
commandName: dispatch.commandName,
|
|
213
|
+
customId: dispatch.customId,
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build the hub inbound-processor job for a dispatchable interaction — the same
|
|
220
|
+
* job shape, on the same queue, that the gateway worker produces for a typed
|
|
221
|
+
* message. Returns `null` when the interaction was invoked by a bot (the
|
|
222
|
+
* feedback-loop guard `buildInboundMessageJob` owns).
|
|
223
|
+
*/
|
|
224
|
+
export function buildInteractionInboundJob(input: {
|
|
225
|
+
dispatch: DispatchableInteraction
|
|
226
|
+
channel: GatewayChannelScope
|
|
227
|
+
botUserId?: string
|
|
228
|
+
}): InboundProcessorPayload | null {
|
|
229
|
+
return buildInboundMessageJob({
|
|
230
|
+
message: buildInteractionMessage(input.dispatch),
|
|
231
|
+
channel: input.channel,
|
|
232
|
+
botUserId: input.botUserId,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Copy shown to the user in Discord once the interaction has been recorded.
|
|
238
|
+
*
|
|
239
|
+
* Takes the translator rather than resolving one, so the pure builder stays
|
|
240
|
+
* usable from the worker, from the route and from a test with no module
|
|
241
|
+
* registry. Each key carries its English fallback: a worker process that never
|
|
242
|
+
* loaded the app dictionaries must still say something a human can read, not
|
|
243
|
+
* echo a translation key back into a Discord channel.
|
|
244
|
+
*/
|
|
245
|
+
export function buildInteractionFollowUpContent(dispatch: DispatchableInteraction, t: TranslateFn): string {
|
|
246
|
+
if (dispatch.type === DiscordInteractionType.APPLICATION_COMMAND) {
|
|
247
|
+
return t(
|
|
248
|
+
'channel_discord.interactions.commandReceived',
|
|
249
|
+
'Received — `/{command}` is now in the Open Mercato inbox and someone will pick it up from there.',
|
|
250
|
+
{ command: dispatch.commandName ?? '' },
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
return t(
|
|
254
|
+
'channel_discord.interactions.componentReceived',
|
|
255
|
+
'Received — your response is now in the Open Mercato inbox and someone will pick it up from there.',
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Replace the deferred acknowledgement with a real message.
|
|
261
|
+
*
|
|
262
|
+
* ORDER MATTERS: editing the original response is what actually ends Discord's
|
|
263
|
+
* "thinking…" state, so it is tried first. A follow-up POST is the fallback for
|
|
264
|
+
* the case where the original response is already gone (Discord answers 404),
|
|
265
|
+
* which still gets the user a visible answer instead of a spinner that never
|
|
266
|
+
* resolves.
|
|
267
|
+
*
|
|
268
|
+
* Returns which call delivered the message so the worker can log it; throws only
|
|
269
|
+
* when neither did, letting the queue's retry policy have another go inside the
|
|
270
|
+
* interaction token's 15-minute lifetime.
|
|
271
|
+
*/
|
|
272
|
+
export async function sendInteractionFollowUp(
|
|
273
|
+
client: DiscordRestClient,
|
|
274
|
+
input: { applicationId: string; interactionToken: string; content: string; ephemeral?: boolean },
|
|
275
|
+
): Promise<'edited-original' | 'follow-up'> {
|
|
276
|
+
const body = { content: input.content, ephemeral: input.ephemeral }
|
|
277
|
+
try {
|
|
278
|
+
await client.editOriginalInteractionResponse(input.applicationId, input.interactionToken, body)
|
|
279
|
+
return 'edited-original'
|
|
280
|
+
} catch (editError) {
|
|
281
|
+
try {
|
|
282
|
+
await client.createInteractionFollowUp(input.applicationId, input.interactionToken, body)
|
|
283
|
+
return 'follow-up'
|
|
284
|
+
} catch {
|
|
285
|
+
throw editError
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { DISCORD_MESSAGE_FLAG_EPHEMERAL } from './discord-rest'
|
|
2
|
+
import {
|
|
3
|
+
isDispatchableInteractionType,
|
|
4
|
+
readDispatchableInteraction,
|
|
5
|
+
type DispatchableInteraction,
|
|
6
|
+
} from './interactions-dispatch'
|
|
7
|
+
import {
|
|
8
|
+
DiscordInteractionResponseType,
|
|
9
|
+
DiscordInteractionType,
|
|
10
|
+
hasVerifiableSignatureHeaders,
|
|
11
|
+
isSignatureTimestampFresh,
|
|
12
|
+
parseInteractionBody,
|
|
13
|
+
readInteractionApplicationId,
|
|
14
|
+
verifyDiscordSignature,
|
|
15
|
+
type TimestampFreshnessOptions,
|
|
16
|
+
} from './interactions-verify'
|
|
17
|
+
|
|
18
|
+
export interface InteractionCandidate {
|
|
19
|
+
channelId: string
|
|
20
|
+
/** Hub channel type, carried through so the dispatch job matches the gateway's. */
|
|
21
|
+
channelType: string
|
|
22
|
+
tenantId: string
|
|
23
|
+
organizationId: string | null
|
|
24
|
+
publicKey: string
|
|
25
|
+
/** Discord application this channel's bot belongs to; `null` when unknown. */
|
|
26
|
+
applicationId: string | null
|
|
27
|
+
/**
|
|
28
|
+
* Scope `integrationCredentialsService.resolve` must be called with to obtain
|
|
29
|
+
* this channel's bot token. Carried instead of the token itself so no
|
|
30
|
+
* credential ever reaches a queue payload.
|
|
31
|
+
*/
|
|
32
|
+
credentialScope: { tenantId: string; organizationId: string; userId: string | null }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The two user-visible strings the synchronous handler can have to emit.
|
|
37
|
+
*
|
|
38
|
+
* They are passed in rather than translated here so the handler stays pure and
|
|
39
|
+
* synchronous — Discord gives the endpoint three seconds, and the caller already
|
|
40
|
+
* knows the locale.
|
|
41
|
+
*/
|
|
42
|
+
export interface InteractionMessages {
|
|
43
|
+
/** Verified, but the payload carries no channel / user / token to dispatch. */
|
|
44
|
+
notDispatchable: string
|
|
45
|
+
/** Verified and well-formed, but this provider does not handle the type. */
|
|
46
|
+
unsupported: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_INTERACTION_MESSAGES: InteractionMessages = {
|
|
50
|
+
notDispatchable:
|
|
51
|
+
'This interaction is missing the channel or user context Open Mercato needs, so it was not recorded.',
|
|
52
|
+
unsupported: 'The Open Mercato Discord channel does not handle this interaction type.',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ephemeralReply(content: string): Record<string, unknown> {
|
|
56
|
+
return {
|
|
57
|
+
type: DiscordInteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
|
|
58
|
+
data: { content, flags: DISCORD_MESSAGE_FLAG_EPHEMERAL },
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What the candidate loader is allowed to narrow by. `applicationId` comes from
|
|
64
|
+
* the unverified request body, so it may only ever shrink the candidate set —
|
|
65
|
+
* see `readInteractionApplicationId`.
|
|
66
|
+
*/
|
|
67
|
+
export interface InteractionCandidateFilter {
|
|
68
|
+
applicationId: string | null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface InteractionResult {
|
|
72
|
+
status: number
|
|
73
|
+
/** JSON body to return (Discord expects a response type for verified interactions). */
|
|
74
|
+
body: Record<string, unknown>
|
|
75
|
+
/** The channel the signature verified against (tenant-pinned). Null on reject. */
|
|
76
|
+
matchedChannel: InteractionCandidate | null
|
|
77
|
+
/**
|
|
78
|
+
* Work the caller MUST hand off when the body above is a deferred
|
|
79
|
+
* acknowledgement. Non-null exactly when the response promises a follow-up:
|
|
80
|
+
* ignoring it is what leaves the user staring at "thinking…" forever.
|
|
81
|
+
*/
|
|
82
|
+
dispatch?: DispatchableInteraction | null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Request-only rejection gate, evaluated before anything tenant-scoped is loaded.
|
|
87
|
+
*
|
|
88
|
+
* WHY IT EXISTS: this endpoint is unauthenticated by design, so any caller can
|
|
89
|
+
* POST to it. Everything decidable from the request alone — a missing, malformed
|
|
90
|
+
* or wrong-length signature header, a missing or stale timestamp — is decided
|
|
91
|
+
* here, so that traffic never reaches the candidate loader and never costs a
|
|
92
|
+
* database round-trip or a credential decrypt.
|
|
93
|
+
*
|
|
94
|
+
* Returns the rejection to send, or `null` when the request is well-formed
|
|
95
|
+
* enough that candidates must be loaded to answer it. It is deliberately
|
|
96
|
+
* signature-*shape* only: whether the signature actually verifies still requires
|
|
97
|
+
* a public key, and that check stays where it was.
|
|
98
|
+
*/
|
|
99
|
+
export function screenInteractionRequest(input: {
|
|
100
|
+
signatureHex: string | undefined | null
|
|
101
|
+
timestamp: string | undefined | null
|
|
102
|
+
freshness?: TimestampFreshnessOptions
|
|
103
|
+
}): InteractionResult | null {
|
|
104
|
+
const { signatureHex, timestamp, freshness } = input
|
|
105
|
+
|
|
106
|
+
if (!isSignatureTimestampFresh(timestamp, freshness)) {
|
|
107
|
+
return { status: 401, body: { error: 'stale_timestamp' }, matchedChannel: null }
|
|
108
|
+
}
|
|
109
|
+
if (!hasVerifiableSignatureHeaders(signatureHex, timestamp)) {
|
|
110
|
+
return { status: 401, body: { error: 'invalid_signature' }, matchedChannel: null }
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Screen the request, then load only the candidates that could plausibly match
|
|
117
|
+
* it, then dispatch.
|
|
118
|
+
*
|
|
119
|
+
* This is the order the HTTP route must follow, expressed once and injectable so
|
|
120
|
+
* it can be tested without a database: `loadCandidates` is not called at all for
|
|
121
|
+
* a request the screening gate rejects, and when it is called it receives the
|
|
122
|
+
* `application_id` narrowing so the Ed25519 fan-out runs over the channels of
|
|
123
|
+
* one Discord application instead of every Discord channel in the installation.
|
|
124
|
+
*
|
|
125
|
+
* The narrowing is a NARROWING, never an authorization decision — the signature
|
|
126
|
+
* gate below is unchanged, so a body that claims someone else's `application_id`
|
|
127
|
+
* still fails unless it also carries that application's signature.
|
|
128
|
+
*/
|
|
129
|
+
export async function resolveDiscordInteraction(input: {
|
|
130
|
+
rawBody: string
|
|
131
|
+
signatureHex: string | undefined | null
|
|
132
|
+
timestamp: string | undefined | null
|
|
133
|
+
loadCandidates: (filter: InteractionCandidateFilter) => Promise<InteractionCandidate[]>
|
|
134
|
+
freshness?: TimestampFreshnessOptions
|
|
135
|
+
messages?: InteractionMessages
|
|
136
|
+
}): Promise<InteractionResult> {
|
|
137
|
+
const { rawBody, signatureHex, timestamp, loadCandidates, freshness, messages } = input
|
|
138
|
+
|
|
139
|
+
const screened = screenInteractionRequest({ signatureHex, timestamp, freshness })
|
|
140
|
+
if (screened) return screened
|
|
141
|
+
|
|
142
|
+
const candidates = await loadCandidates({ applicationId: readInteractionApplicationId(rawBody) })
|
|
143
|
+
return handleDiscordInteraction({ rawBody, signatureHex, timestamp, candidates, freshness, messages })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Core Discord Interactions dispatch — pure and testable (no HTTP/DB).
|
|
148
|
+
*
|
|
149
|
+
* SECURITY (fail-closed): the request is rejected (401) unless its Ed25519
|
|
150
|
+
* signature verifies against exactly one candidate channel's public key. The
|
|
151
|
+
* matched channel pins the tenant, so one tenant's interaction never lands in
|
|
152
|
+
* another tenant's scope. A tampered / missing signature verifies against no
|
|
153
|
+
* candidate → 401, and no tenant-scoped work is done.
|
|
154
|
+
*
|
|
155
|
+
* REPLAY GUARD: the signed timestamp must be within
|
|
156
|
+
* `DISCORD_SIGNATURE_MAX_SKEW_SECONDS` of the server clock. The check runs
|
|
157
|
+
* BEFORE the per-candidate Ed25519 fan-out, so a replayed capture (still
|
|
158
|
+
* cryptographically valid forever) is rejected without any verify work.
|
|
159
|
+
*
|
|
160
|
+
* COST: this function only decides what to do with candidates it is handed.
|
|
161
|
+
* Callers that have to *load* those candidates first must run
|
|
162
|
+
* `screenInteractionRequest` before doing so — that is what keeps an unsigned or
|
|
163
|
+
* stale request genuinely constant-cost rather than merely cheap after the
|
|
164
|
+
* database has already been read (`resolveDiscordInteraction` does it for them).
|
|
165
|
+
*
|
|
166
|
+
* On the mandatory PING (type 1) handshake it returns `{ type: 1 }` (PONG) so
|
|
167
|
+
* Discord accepts the endpoint URL.
|
|
168
|
+
*
|
|
169
|
+
* Application commands, message components and modal submissions return a
|
|
170
|
+
* deferred acknowledgement TOGETHER WITH the `dispatch` the caller must hand to
|
|
171
|
+
* the interactions worker: that worker normalizes the interaction into the hub's
|
|
172
|
+
* existing inbound path and replaces the deferred ack with a real message. The
|
|
173
|
+
* two travel in one result on purpose — a deferred ack nobody follows up is the
|
|
174
|
+
* permanent "thinking…" state this endpoint used to produce.
|
|
175
|
+
*
|
|
176
|
+
* Autocomplete is answered synchronously with an empty choice list, the only
|
|
177
|
+
* response Discord accepts for it, and anything else gets a visible, ephemeral
|
|
178
|
+
* "not handled" reply rather than a promise this provider cannot keep.
|
|
179
|
+
*/
|
|
180
|
+
export function handleDiscordInteraction(input: {
|
|
181
|
+
rawBody: string
|
|
182
|
+
signatureHex: string | undefined | null
|
|
183
|
+
timestamp: string | undefined | null
|
|
184
|
+
candidates: InteractionCandidate[]
|
|
185
|
+
freshness?: TimestampFreshnessOptions
|
|
186
|
+
messages?: InteractionMessages
|
|
187
|
+
}): InteractionResult {
|
|
188
|
+
const { rawBody, signatureHex, timestamp, candidates, freshness } = input
|
|
189
|
+
const messages = input.messages ?? DEFAULT_INTERACTION_MESSAGES
|
|
190
|
+
|
|
191
|
+
const screened = screenInteractionRequest({ signatureHex, timestamp, freshness })
|
|
192
|
+
if (screened) return screened
|
|
193
|
+
|
|
194
|
+
let matched: InteractionCandidate | null = null
|
|
195
|
+
for (const candidate of candidates) {
|
|
196
|
+
const ok = verifyDiscordSignature({
|
|
197
|
+
publicKeyHex: candidate.publicKey,
|
|
198
|
+
signatureHex,
|
|
199
|
+
timestamp,
|
|
200
|
+
rawBody,
|
|
201
|
+
})
|
|
202
|
+
if (ok) {
|
|
203
|
+
matched = candidate
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!matched) {
|
|
209
|
+
// FAIL-CLOSED — never acknowledge an unverified interaction.
|
|
210
|
+
return { status: 401, body: { error: 'invalid_signature' }, matchedChannel: null }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const interaction = parseInteractionBody(rawBody)
|
|
214
|
+
if (!interaction) {
|
|
215
|
+
return { status: 400, body: { error: 'invalid_interaction' }, matchedChannel: matched }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (interaction.type === DiscordInteractionType.PING) {
|
|
219
|
+
return {
|
|
220
|
+
status: 200,
|
|
221
|
+
body: { type: DiscordInteractionResponseType.PONG },
|
|
222
|
+
matchedChannel: matched,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (isDispatchableInteractionType(interaction.type)) {
|
|
227
|
+
const dispatch = readDispatchableInteraction(interaction)
|
|
228
|
+
if (!dispatch) {
|
|
229
|
+
// Deferring here would be a promise we cannot keep: without the follow-up
|
|
230
|
+
// token or the channel there is nothing to edit the placeholder with.
|
|
231
|
+
return {
|
|
232
|
+
status: 200,
|
|
233
|
+
body: ephemeralReply(messages.notDispatchable),
|
|
234
|
+
matchedChannel: matched,
|
|
235
|
+
dispatch: null,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
status: 200,
|
|
240
|
+
body: { type: DiscordInteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE },
|
|
241
|
+
matchedChannel: matched,
|
|
242
|
+
dispatch,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (interaction.type === DiscordInteractionType.APPLICATION_COMMAND_AUTOCOMPLETE) {
|
|
247
|
+
// Discord rejects a deferred ack for autocomplete. No command this provider
|
|
248
|
+
// registers declares an autocompleting option, so an empty list is the
|
|
249
|
+
// honest answer and it closes the request immediately.
|
|
250
|
+
return {
|
|
251
|
+
status: 200,
|
|
252
|
+
body: {
|
|
253
|
+
type: DiscordInteractionResponseType.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT,
|
|
254
|
+
data: { choices: [] },
|
|
255
|
+
},
|
|
256
|
+
matchedChannel: matched,
|
|
257
|
+
dispatch: null,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
status: 200,
|
|
263
|
+
body: ephemeralReply(messages.unsupported),
|
|
264
|
+
matchedChannel: matched,
|
|
265
|
+
dispatch: null,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createModuleQueue, type Queue } from '@open-mercato/queue'
|
|
2
|
+
import type { DispatchableInteraction } from './interactions-dispatch'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Queue carrying verified Discord interactions from the HTTP route to the
|
|
6
|
+
* dispatch worker.
|
|
7
|
+
*
|
|
8
|
+
* It lives here rather than in the worker so the route never imports a worker
|
|
9
|
+
* module: `workers/` is an auto-discovery path, and a route that pulled one in
|
|
10
|
+
* would drag the worker's dependency graph into the request bundle.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the hub's `getCommunicationChannelsQueue` — one memoized instance per
|
|
13
|
+
* name, so the route and the worker share it instead of constructing a queue per
|
|
14
|
+
* request.
|
|
15
|
+
*/
|
|
16
|
+
export const CHANNEL_DISCORD_INTERACTIONS_QUEUE = 'channel_discord_interactions'
|
|
17
|
+
|
|
18
|
+
const INTERACTIONS_QUEUE_CONCURRENCY = 5
|
|
19
|
+
|
|
20
|
+
export type InteractionDispatchJobPayload = {
|
|
21
|
+
channelId: string
|
|
22
|
+
channelType: string
|
|
23
|
+
tenantId: string
|
|
24
|
+
organizationId: string | null
|
|
25
|
+
/**
|
|
26
|
+
* Scope the **bot token** is re-resolved with on the worker side. The bot token
|
|
27
|
+
* itself is deliberately absent, because the local queue strategy persists
|
|
28
|
+
* payloads to disk as plain JSON and that token is long-lived.
|
|
29
|
+
*
|
|
30
|
+
* This is not a blanket "no credential travels on this payload" guarantee, and
|
|
31
|
+
* reading it as one would be a mistake: `interaction.token` below IS a
|
|
32
|
+
* credential. It is Discord's interaction webhook token, and on its own — with
|
|
33
|
+
* no bot token at all — it can post as the application (the `auth === null`
|
|
34
|
+
* calls in `discord-rest.ts`), so it does land on disk under the local
|
|
35
|
+
* strategy. Discord expires it 15 minutes after the interaction, which bounds
|
|
36
|
+
* the exposure rather than removing it. `interactions-dispatch.ts` states the
|
|
37
|
+
* same constraint from the other side.
|
|
38
|
+
*/
|
|
39
|
+
credentialScope: { tenantId: string; organizationId: string; userId: string | null }
|
|
40
|
+
interaction: DispatchableInteraction
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let cachedQueue: Queue<Record<string, unknown>> | null = null
|
|
44
|
+
|
|
45
|
+
export function getInteractionDispatchQueue(): Queue<Record<string, unknown>> {
|
|
46
|
+
if (!cachedQueue) {
|
|
47
|
+
cachedQueue = createModuleQueue<Record<string, unknown>>(CHANNEL_DISCORD_INTERACTIONS_QUEUE, {
|
|
48
|
+
concurrency: INTERACTIONS_QUEUE_CONCURRENCY,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
return cachedQueue
|
|
52
|
+
}
|