@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,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeInboundDiscordMessage,
|
|
3
|
+
isAuthoredByBot,
|
|
4
|
+
DISCORD_CONVERSATION_PREFIX,
|
|
5
|
+
} from '../normalize-inbound'
|
|
6
|
+
import type { DiscordMessageObject } from '../discord-rest'
|
|
7
|
+
|
|
8
|
+
function makeMessage(overrides: Partial<DiscordMessageObject> = {}): DiscordMessageObject {
|
|
9
|
+
return {
|
|
10
|
+
id: 'msg-1',
|
|
11
|
+
channel_id: 'chan-9',
|
|
12
|
+
guild_id: 'guild-3',
|
|
13
|
+
content: 'hello world',
|
|
14
|
+
author: { id: 'user-42', username: 'alice', global_name: 'Alice A.' },
|
|
15
|
+
timestamp: '2026-06-19T10:00:00.000Z',
|
|
16
|
+
...overrides,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('normalizeInboundDiscordMessage', () => {
|
|
21
|
+
it('maps a Discord message to the hub NormalizedInboundMessage shape', () => {
|
|
22
|
+
const result = normalizeInboundDiscordMessage(makeMessage())
|
|
23
|
+
expect(result.externalMessageId).toBe('msg-1')
|
|
24
|
+
expect(result.externalConversationId).toBe(`${DISCORD_CONVERSATION_PREFIX}chan-9`)
|
|
25
|
+
expect(result.senderIdentifier).toBe('user-42')
|
|
26
|
+
expect(result.senderDisplayName).toBe('Alice A.')
|
|
27
|
+
expect(result.body).toBe('hello world')
|
|
28
|
+
expect(result.bodyFormat).toBe('markdown')
|
|
29
|
+
expect(result.channelContentType).toBe('discord')
|
|
30
|
+
expect(result.channelMetadata.discordChannelId).toBe('chan-9')
|
|
31
|
+
expect(result.channelMetadata.discordGuildId).toBe('guild-3')
|
|
32
|
+
expect(result.timestamp.toISOString()).toBe('2026-06-19T10:00:00.000Z')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('carries reply-to from message_reference and maps attachments', () => {
|
|
36
|
+
const result = normalizeInboundDiscordMessage(
|
|
37
|
+
makeMessage({
|
|
38
|
+
message_reference: { message_id: 'parent-7' },
|
|
39
|
+
attachments: [{ id: 'a1', url: 'https://cdn/x.png', filename: 'x.png', content_type: 'image/png', size: 10 }],
|
|
40
|
+
}),
|
|
41
|
+
)
|
|
42
|
+
expect(result.replyToExternalId).toBe('parent-7')
|
|
43
|
+
expect(result.attachments?.[0]).toMatchObject({ url: 'https://cdn/x.png', mimeType: 'image/png', fileName: 'x.png' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('falls back to username when global_name is absent', () => {
|
|
47
|
+
const result = normalizeInboundDiscordMessage(
|
|
48
|
+
makeMessage({ author: { id: 'u', username: 'bob', global_name: null } }),
|
|
49
|
+
)
|
|
50
|
+
expect(result.senderDisplayName).toBe('bob')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('isAuthoredByBot', () => {
|
|
55
|
+
it('is true when the author id matches the bot user id', () => {
|
|
56
|
+
expect(isAuthoredByBot(makeMessage({ author: { id: 'bot-1', username: 'bot' } }), 'bot-1')).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
it('is false for a non-bot human author', () => {
|
|
59
|
+
expect(isAuthoredByBot(makeMessage({ author: { id: 'user-42', username: 'alice' } }), 'bot-1')).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
it('is true for ANY bot author, not just our own bot (cross-bot loop guard)', () => {
|
|
62
|
+
expect(isAuthoredByBot(makeMessage({ author: { id: 'other-bot', username: 'webhook', bot: true } }), 'bot-1')).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
it('falls back to the bot flag when no bot id is known', () => {
|
|
65
|
+
expect(isAuthoredByBot(makeMessage({ author: { id: 'x', username: 'x', bot: true } }), undefined)).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { applyDiscordEnvPreset, readDiscordEnvPreset } from '../preset'
|
|
2
|
+
|
|
3
|
+
const PUBLIC_KEY = 'a'.repeat(64)
|
|
4
|
+
|
|
5
|
+
const COMPLETE_ENV = {
|
|
6
|
+
OM_CHANNEL_DISCORD_BOT_TOKEN: 'bot-token',
|
|
7
|
+
OM_CHANNEL_DISCORD_APPLICATION_ID: 'app-1',
|
|
8
|
+
OM_CHANNEL_DISCORD_PUBLIC_KEY: PUBLIC_KEY,
|
|
9
|
+
} as NodeJS.ProcessEnv
|
|
10
|
+
|
|
11
|
+
describe('readDiscordEnvPreset', () => {
|
|
12
|
+
it('returns null when no Discord preset variable is set (interactive connect flow)', () => {
|
|
13
|
+
expect(readDiscordEnvPreset({} as NodeJS.ProcessEnv)).toBeNull()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('parses a complete preset including the optional guild + default channel', () => {
|
|
17
|
+
const preset = readDiscordEnvPreset({
|
|
18
|
+
...COMPLETE_ENV,
|
|
19
|
+
OM_CHANNEL_DISCORD_GUILD_ID: 'guild-1',
|
|
20
|
+
OM_CHANNEL_DISCORD_DEFAULT_CHANNEL_ID: 'chan-1',
|
|
21
|
+
} as NodeJS.ProcessEnv)
|
|
22
|
+
|
|
23
|
+
expect(preset?.credentials).toMatchObject({
|
|
24
|
+
botToken: 'bot-token',
|
|
25
|
+
applicationId: 'app-1',
|
|
26
|
+
publicKey: PUBLIC_KEY,
|
|
27
|
+
guildId: 'guild-1',
|
|
28
|
+
defaultChannelId: 'chan-1',
|
|
29
|
+
})
|
|
30
|
+
expect(preset?.force).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('fails loudly on a half-filled preset instead of persisting unusable credentials', () => {
|
|
34
|
+
expect(() =>
|
|
35
|
+
readDiscordEnvPreset({ OM_CHANNEL_DISCORD_BOT_TOKEN: 'bot-token' } as NodeJS.ProcessEnv),
|
|
36
|
+
).toThrow(/Incomplete Discord env preset/)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('reads the force flag', () => {
|
|
40
|
+
const preset = readDiscordEnvPreset({
|
|
41
|
+
...COMPLETE_ENV,
|
|
42
|
+
OM_CHANNEL_DISCORD_FORCE_PRECONFIGURE: 'true',
|
|
43
|
+
} as NodeJS.ProcessEnv)
|
|
44
|
+
expect(preset?.force).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('applyDiscordEnvPreset', () => {
|
|
49
|
+
const scope = { tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
50
|
+
|
|
51
|
+
function fakeCredentialsService(existing: unknown) {
|
|
52
|
+
return {
|
|
53
|
+
getRaw: jest.fn().mockResolvedValue(existing),
|
|
54
|
+
save: jest.fn().mockResolvedValue(undefined),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it('skips when no preset env is present', async () => {
|
|
59
|
+
const credentialsService = fakeCredentialsService(null)
|
|
60
|
+
const result = await applyDiscordEnvPreset({
|
|
61
|
+
credentialsService: credentialsService as never,
|
|
62
|
+
scope,
|
|
63
|
+
env: {} as NodeJS.ProcessEnv,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual({ status: 'skipped', reason: 'No Discord preset env variables were provided.' })
|
|
67
|
+
expect(credentialsService.save).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('persists the preset through the credential service on a fresh tenant', async () => {
|
|
71
|
+
const credentialsService = fakeCredentialsService(null)
|
|
72
|
+
const result = await applyDiscordEnvPreset({
|
|
73
|
+
credentialsService: credentialsService as never,
|
|
74
|
+
scope,
|
|
75
|
+
env: COMPLETE_ENV,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(result).toEqual({ status: 'configured' })
|
|
79
|
+
expect(credentialsService.save).toHaveBeenCalledWith(
|
|
80
|
+
'channel_discord',
|
|
81
|
+
expect.objectContaining({ botToken: 'bot-token', applicationId: 'app-1' }),
|
|
82
|
+
scope,
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('leaves credentials connected through the UI untouched unless forced', async () => {
|
|
87
|
+
const credentialsService = fakeCredentialsService({ botToken: 'existing' })
|
|
88
|
+
const result = await applyDiscordEnvPreset({
|
|
89
|
+
credentialsService: credentialsService as never,
|
|
90
|
+
scope,
|
|
91
|
+
env: COMPLETE_ENV,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect(result.status).toBe('skipped')
|
|
95
|
+
expect(credentialsService.save).not.toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('overwrites existing credentials when forced', async () => {
|
|
99
|
+
const credentialsService = fakeCredentialsService({ botToken: 'existing' })
|
|
100
|
+
const result = await applyDiscordEnvPreset({
|
|
101
|
+
credentialsService: credentialsService as never,
|
|
102
|
+
scope,
|
|
103
|
+
force: true,
|
|
104
|
+
env: COMPLETE_ENV,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
expect(result).toEqual({ status: 'configured' })
|
|
108
|
+
expect(credentialsService.save).toHaveBeenCalledTimes(1)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { assertInboundDeliverable } from '../queue-strategy'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Regression guard for #4978.
|
|
5
|
+
*
|
|
6
|
+
* `start-gateway` runs a worker for the `channel_discord_gateway` queue only,
|
|
7
|
+
* while the bridge enqueues into `communication-channels-inbound` / `-reactions`,
|
|
8
|
+
* whose workers live in the app server process. Under the DEFAULT
|
|
9
|
+
* `QUEUE_STRATEGY=local` the queue is in-process, so those jobs never cross the
|
|
10
|
+
* process boundary — QA saw zero rows, zero errors, zero log lines, and had to
|
|
11
|
+
* reach for an external WebSocket probe to tell a dead socket from a lost job.
|
|
12
|
+
*/
|
|
13
|
+
describe('assertInboundDeliverable', () => {
|
|
14
|
+
it('allows the gateway to start when the queue is async', () => {
|
|
15
|
+
expect(() => assertInboundDeliverable('async')).not.toThrow()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('refuses to start on the default in-process strategy', () => {
|
|
19
|
+
expect(() => assertInboundDeliverable(undefined)).toThrow(/QUEUE_STRATEGY=async/)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('refuses to start when local is set explicitly', () => {
|
|
23
|
+
expect(() => assertInboundDeliverable('local')).toThrow(/QUEUE_STRATEGY=async/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('refuses an unrecognised value rather than guessing', () => {
|
|
27
|
+
expect(() => assertInboundDeliverable('redis')).toThrow(/QUEUE_STRATEGY=async/)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('explains the consequence, not just the rule', () => {
|
|
31
|
+
expect(() => assertInboundDeliverable('local')).toThrow(/silently dropped/)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelAdapter,
|
|
3
|
+
ChannelNativeContent,
|
|
4
|
+
ContactHint,
|
|
5
|
+
ConvertOutboundInput,
|
|
6
|
+
DeleteChannelMessageInput,
|
|
7
|
+
EditChannelMessageInput,
|
|
8
|
+
FetchHistoryInput,
|
|
9
|
+
GetMessageStatusInput,
|
|
10
|
+
HistoryPage,
|
|
11
|
+
InboundMessage,
|
|
12
|
+
InboundReactionEvent,
|
|
13
|
+
MessageStatus,
|
|
14
|
+
NormalizedInboundMessage,
|
|
15
|
+
RemoveReactionInput,
|
|
16
|
+
ResolveContactInput,
|
|
17
|
+
SendMessageInput,
|
|
18
|
+
SendMessageResult,
|
|
19
|
+
SendReactionInput,
|
|
20
|
+
ValidateCredentialsInput,
|
|
21
|
+
ValidateCredentialsResult,
|
|
22
|
+
VerifyWebhookInput,
|
|
23
|
+
} from '@open-mercato/core/modules/communication_channels/lib/adapter'
|
|
24
|
+
import { discordCapabilities } from './capabilities'
|
|
25
|
+
import { parseDiscordCredentialsOrThrow, discordCredentialsSchema } from './credentials'
|
|
26
|
+
import {
|
|
27
|
+
DiscordApiError,
|
|
28
|
+
getDiscordRestClient,
|
|
29
|
+
type DiscordMessageObject,
|
|
30
|
+
} from './discord-rest'
|
|
31
|
+
import { convertOutboundForDiscord } from './convert-outbound'
|
|
32
|
+
import {
|
|
33
|
+
DISCORD_CONVERSATION_PREFIX,
|
|
34
|
+
normalizeInboundDiscordMessage,
|
|
35
|
+
} from './normalize-inbound'
|
|
36
|
+
import {
|
|
37
|
+
parseInteractionBody,
|
|
38
|
+
verifyDiscordSignature,
|
|
39
|
+
} from './interactions-verify'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Discord `ChannelAdapter` (SPEC 2026-06-19).
|
|
43
|
+
*
|
|
44
|
+
* Transport split:
|
|
45
|
+
* - Outbound: Discord REST API (`sendMessage`, edit/delete/reactions).
|
|
46
|
+
* - Inbound messages: a provider-owned **Gateway worker** pushes
|
|
47
|
+
* `MESSAGE_CREATE` events into the hub's ingest command — NOT this
|
|
48
|
+
* `verifyWebhook` hook (Discord does not POST normal messages to a webhook).
|
|
49
|
+
* - Slash commands / buttons: a signed **Interactions** endpoint
|
|
50
|
+
* (`api/interactions` → `/api/channel_discord/interactions`) with Ed25519 verification.
|
|
51
|
+
*
|
|
52
|
+
* `verifyWebhook` security contract (fail-closed): the shared
|
|
53
|
+
* `api/post/webhook/[provider]` route treats a non-throwing return as "verified".
|
|
54
|
+
* For a Discord interaction body we verify the Ed25519 signature and THROW on any
|
|
55
|
+
* failure; for a non-interaction body (a normal message never arrives here) we
|
|
56
|
+
* return `eventType: 'other'` so the generic route acks without tenant-scoped
|
|
57
|
+
* work. Live PING→PONG handshakes are served by the dedicated interactions route,
|
|
58
|
+
* which can answer synchronously (the generic route only 202-acks).
|
|
59
|
+
*/
|
|
60
|
+
class DiscordChannelAdapter implements ChannelAdapter {
|
|
61
|
+
readonly providerKey = 'discord'
|
|
62
|
+
readonly channelType = 'discord'
|
|
63
|
+
readonly capabilities = discordCapabilities
|
|
64
|
+
|
|
65
|
+
async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
|
|
66
|
+
const credentials = parseDiscordCredentialsOrThrow(input.credentials)
|
|
67
|
+
|
|
68
|
+
let native: ChannelNativeContent
|
|
69
|
+
try {
|
|
70
|
+
native = await convertOutboundForDiscord({
|
|
71
|
+
body: input.content.text ?? input.content.html ?? '',
|
|
72
|
+
bodyFormat: input.content.bodyFormat ?? (input.content.html ? 'html' : 'text'),
|
|
73
|
+
attachments: input.content.attachments,
|
|
74
|
+
channelMetadata: input.metadata,
|
|
75
|
+
})
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : 'Outbound conversion failed'
|
|
78
|
+
return { externalMessageId: '', status: 'failed', error: message }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const targetChannelId = resolveTargetChannelId(input, credentials.defaultChannelId)
|
|
82
|
+
if (!targetChannelId) {
|
|
83
|
+
return {
|
|
84
|
+
externalMessageId: '',
|
|
85
|
+
status: 'failed',
|
|
86
|
+
error: '[internal] Discord send requires a target channel id (conversationId or defaultChannelId)',
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const meta = (native.metadata ?? {}) as Record<string, unknown>
|
|
91
|
+
try {
|
|
92
|
+
const message = await getDiscordRestClient().createMessage(
|
|
93
|
+
{ botToken: credentials.botToken },
|
|
94
|
+
{
|
|
95
|
+
channelId: targetChannelId,
|
|
96
|
+
content: native.content.text ?? '',
|
|
97
|
+
messageReferenceId: typeof meta.messageReferenceId === 'string' ? meta.messageReferenceId : undefined,
|
|
98
|
+
allowedMentions: (meta.allowedMentions as Record<string, unknown> | undefined) ?? { parse: [] },
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
return {
|
|
102
|
+
externalMessageId: message.id,
|
|
103
|
+
conversationId: `${DISCORD_CONVERSATION_PREFIX}${message.channel_id}`,
|
|
104
|
+
status: 'sent',
|
|
105
|
+
metadata: { discordChannelId: message.channel_id, discordMessageId: message.id },
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof DiscordApiError && error.status === 401) {
|
|
109
|
+
// Protocol sentinel the hub keys on (error-classification.isReauthError).
|
|
110
|
+
return { externalMessageId: '', status: 'failed', error: 'requires_reauth' }
|
|
111
|
+
}
|
|
112
|
+
const message = error instanceof Error ? error.message : 'Discord send failed'
|
|
113
|
+
return { externalMessageId: '', status: 'failed', error: message }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async verifyWebhook(input: VerifyWebhookInput): Promise<InboundMessage> {
|
|
118
|
+
const rawBody = typeof input.rawBody === 'string' ? input.rawBody : input.rawBody.toString('utf-8')
|
|
119
|
+
const interaction = parseInteractionBody(rawBody)
|
|
120
|
+
if (!interaction) {
|
|
121
|
+
// Not an interaction payload — Discord messages arrive via the gateway
|
|
122
|
+
// worker, so ack (202) without tenant-scoped work.
|
|
123
|
+
return { raw: {}, eventType: 'other', metadata: { reason: 'discord-uses-gateway-for-messages' } }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const parsed = discordCredentialsSchema.safeParse(input.credentials)
|
|
127
|
+
const publicKeyHex = parsed.success ? parsed.data.publicKey : ''
|
|
128
|
+
const headers = normalizeHeaders(input.headers)
|
|
129
|
+
const ok = verifyDiscordSignature({
|
|
130
|
+
publicKeyHex,
|
|
131
|
+
signatureHex: headers['x-signature-ed25519'],
|
|
132
|
+
timestamp: headers['x-signature-timestamp'],
|
|
133
|
+
rawBody,
|
|
134
|
+
})
|
|
135
|
+
if (!ok) {
|
|
136
|
+
// FAIL-CLOSED: throwing makes the shared route reject (401) and pins nothing.
|
|
137
|
+
throw new Error('[internal] Discord interaction signature verification failed')
|
|
138
|
+
}
|
|
139
|
+
// Verified interaction. The generic hub route cannot answer the synchronous
|
|
140
|
+
// PING→PONG handshake, so real interactions are served by the dedicated
|
|
141
|
+
// `api/interactions` → `/api/channel_discord/interactions` route; here we only prove the
|
|
142
|
+
// signature and ack.
|
|
143
|
+
return { raw: { discordInteraction: interaction }, eventType: 'other', metadata: { interactionType: interaction.type } }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {
|
|
147
|
+
// Discord exposes no per-message delivery-status API; best-effort placeholder.
|
|
148
|
+
return { status: 'sent' }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {
|
|
152
|
+
return convertOutboundForDiscord(input)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {
|
|
156
|
+
const message = pickDiscordMessage(raw)
|
|
157
|
+
return normalizeInboundDiscordMessage(message)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async normalizeInboundReaction(raw: InboundMessage): Promise<InboundReactionEvent> {
|
|
161
|
+
const payload = (raw.raw?.discordReaction ?? raw.raw) as {
|
|
162
|
+
message_id?: string
|
|
163
|
+
channel_id?: string
|
|
164
|
+
emoji?: { name?: string; id?: string | null }
|
|
165
|
+
user_id?: string
|
|
166
|
+
member?: { user?: { username?: string; global_name?: string | null } }
|
|
167
|
+
action?: 'added' | 'removed'
|
|
168
|
+
}
|
|
169
|
+
const emojiName = payload.emoji?.name ?? ''
|
|
170
|
+
const emoji = payload.emoji?.id ? `${emojiName}:${payload.emoji.id}` : emojiName
|
|
171
|
+
return {
|
|
172
|
+
externalMessageId: payload.message_id ?? '',
|
|
173
|
+
externalConversationId: payload.channel_id
|
|
174
|
+
? `${DISCORD_CONVERSATION_PREFIX}${payload.channel_id}`
|
|
175
|
+
: undefined,
|
|
176
|
+
emoji,
|
|
177
|
+
userIdentifier: payload.user_id ?? 'unknown',
|
|
178
|
+
userDisplayName: payload.member?.user?.global_name ?? payload.member?.user?.username,
|
|
179
|
+
action: payload.action === 'removed' ? 'removed' : 'added',
|
|
180
|
+
raw: payload as Record<string, unknown>,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async sendReaction(input: SendReactionInput): Promise<void> {
|
|
185
|
+
const credentials = parseDiscordCredentialsOrThrow(input.credentials)
|
|
186
|
+
const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId
|
|
187
|
+
if (!channelId) throw new Error('[internal] Discord sendReaction requires a channel id')
|
|
188
|
+
await getDiscordRestClient().addReaction(
|
|
189
|
+
{ botToken: credentials.botToken },
|
|
190
|
+
channelId,
|
|
191
|
+
input.externalMessageId,
|
|
192
|
+
input.emoji,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async removeReaction(input: RemoveReactionInput): Promise<void> {
|
|
197
|
+
const credentials = parseDiscordCredentialsOrThrow(input.credentials)
|
|
198
|
+
const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId
|
|
199
|
+
if (!channelId) throw new Error('[internal] Discord removeReaction requires a channel id')
|
|
200
|
+
await getDiscordRestClient().removeReaction(
|
|
201
|
+
{ botToken: credentials.botToken },
|
|
202
|
+
channelId,
|
|
203
|
+
input.externalMessageId,
|
|
204
|
+
input.emoji,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async editMessage(input: EditChannelMessageInput): Promise<void> {
|
|
209
|
+
const credentials = parseDiscordCredentialsOrThrow(input.credentials)
|
|
210
|
+
const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId
|
|
211
|
+
if (!channelId) throw new Error('[internal] Discord editMessage requires a channel id')
|
|
212
|
+
const content = input.newContent.text ?? input.newContent.html ?? ''
|
|
213
|
+
await getDiscordRestClient().editMessage(
|
|
214
|
+
{ botToken: credentials.botToken },
|
|
215
|
+
channelId,
|
|
216
|
+
input.externalMessageId,
|
|
217
|
+
content,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async deleteMessage(input: DeleteChannelMessageInput): Promise<void> {
|
|
222
|
+
const credentials = parseDiscordCredentialsOrThrow(input.credentials)
|
|
223
|
+
const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId
|
|
224
|
+
if (!channelId) throw new Error('[internal] Discord deleteMessage requires a channel id')
|
|
225
|
+
await getDiscordRestClient().deleteMessage(
|
|
226
|
+
{ botToken: credentials.botToken },
|
|
227
|
+
channelId,
|
|
228
|
+
input.externalMessageId,
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async fetchHistory(input: FetchHistoryInput): Promise<HistoryPage> {
|
|
233
|
+
const credentials = parseDiscordCredentialsOrThrow(input.credentials)
|
|
234
|
+
const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId
|
|
235
|
+
if (!channelId) {
|
|
236
|
+
return { messages: [], hasMore: false }
|
|
237
|
+
}
|
|
238
|
+
const limit = Math.min(Math.max(input.limit ?? 50, 1), 100)
|
|
239
|
+
const raw = await getDiscordRestClient().listMessages(
|
|
240
|
+
{ botToken: credentials.botToken },
|
|
241
|
+
channelId,
|
|
242
|
+
{ before: input.cursor, limit },
|
|
243
|
+
)
|
|
244
|
+
// Discord returns newest-first; keep that order but expose the oldest id as
|
|
245
|
+
// the next `before` cursor for backward pagination.
|
|
246
|
+
const messages = raw.map((message) => normalizeInboundDiscordMessage(message))
|
|
247
|
+
const oldest = raw.length > 0 ? raw[raw.length - 1].id : undefined
|
|
248
|
+
return {
|
|
249
|
+
messages,
|
|
250
|
+
nextCursor: raw.length >= limit ? oldest : undefined,
|
|
251
|
+
hasMore: raw.length >= limit,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async resolveContact(input: ResolveContactInput): Promise<ContactHint | null> {
|
|
256
|
+
const userId = input.senderIdentifier
|
|
257
|
+
if (!userId || userId === 'unknown') return null
|
|
258
|
+
const displayName =
|
|
259
|
+
input.senderDisplayName ||
|
|
260
|
+
(typeof input.channelMetadata?.discordAuthorUsername === 'string'
|
|
261
|
+
? (input.channelMetadata.discordAuthorUsername as string)
|
|
262
|
+
: undefined)
|
|
263
|
+
return {
|
|
264
|
+
displayName,
|
|
265
|
+
externalProfileUrl: `https://discord.com/users/${encodeURIComponent(userId)}`,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async validateCredentials(input: ValidateCredentialsInput): Promise<ValidateCredentialsResult> {
|
|
270
|
+
const parsed = discordCredentialsSchema.safeParse(input.credentials)
|
|
271
|
+
if (!parsed.success) {
|
|
272
|
+
const errors: Record<string, string> = {}
|
|
273
|
+
for (const issue of parsed.error.issues) {
|
|
274
|
+
const path = issue.path[0]
|
|
275
|
+
if (typeof path !== 'string') continue
|
|
276
|
+
if (!errors[path]) errors[path] = issue.message
|
|
277
|
+
}
|
|
278
|
+
return { ok: false, errors }
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
await getDiscordRestClient().getCurrentUser({ botToken: parsed.data.botToken })
|
|
282
|
+
return { ok: true }
|
|
283
|
+
} catch (error) {
|
|
284
|
+
const status = error instanceof DiscordApiError ? error.status : 0
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
errors: {
|
|
288
|
+
botToken:
|
|
289
|
+
status === 401
|
|
290
|
+
? 'Discord rejected the bot token. Reset it in the Developer Portal and paste the new value.'
|
|
291
|
+
: 'Could not reach Discord to validate the bot token. Check the token and try again.',
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function normalizeHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string> {
|
|
299
|
+
const out: Record<string, string> = {}
|
|
300
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
301
|
+
if (value === undefined) continue
|
|
302
|
+
out[key.toLowerCase()] = Array.isArray(value) ? (value[0] ?? '') : value
|
|
303
|
+
}
|
|
304
|
+
return out
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function stripConversationPrefix(conversationId: string | undefined): string | undefined {
|
|
308
|
+
if (!conversationId) return undefined
|
|
309
|
+
return conversationId.startsWith(DISCORD_CONVERSATION_PREFIX)
|
|
310
|
+
? conversationId.slice(DISCORD_CONVERSATION_PREFIX.length)
|
|
311
|
+
: conversationId
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Where an outbound message is actually posted, in priority order.
|
|
316
|
+
*
|
|
317
|
+
* `metadata.to` is in this list because of what QA found on #4391: the hub's
|
|
318
|
+
* `test-send` route puts the operator's recipient in `metadata.to`, nothing
|
|
319
|
+
* here read it, and every send therefore went to `defaultChannelId` while the
|
|
320
|
+
* endpoint answered `200 sent`. Validating a recipient and then discarding it
|
|
321
|
+
* is worse than rejecting it — an operator test-sending to one channel got a
|
|
322
|
+
* success for a message that landed in another.
|
|
323
|
+
*
|
|
324
|
+
* `conversationId` deliberately outranks `metadata.to`: on a reply the thread
|
|
325
|
+
* mapping is authoritative, and a stray `to` must never redirect a reply out of
|
|
326
|
+
* its own conversation. `metadata.to` only decides when there is no
|
|
327
|
+
* conversation to belong to, which is exactly the test-send case.
|
|
328
|
+
*
|
|
329
|
+
* With `capabilities.recipientFormat: 'provider-native'` the hub has already
|
|
330
|
+
* applied its transport-safety checks to this value, but it stays untrusted
|
|
331
|
+
* input: it reaches `/channels/{id}/messages` and a wrong-but-well-formed id
|
|
332
|
+
* simply fails at Discord, which is the honest outcome.
|
|
333
|
+
*/
|
|
334
|
+
function resolveTargetChannelId(input: SendMessageInput, defaultChannelId: string | undefined): string | undefined {
|
|
335
|
+
const fromMeta = input.metadata?.discordChannelId
|
|
336
|
+
if (typeof fromMeta === 'string' && fromMeta.length > 0) return fromMeta
|
|
337
|
+
const fromConversation = stripConversationPrefix(input.conversationId)
|
|
338
|
+
if (fromConversation && fromConversation.length > 0) return fromConversation
|
|
339
|
+
const fromRecipient = input.metadata?.to
|
|
340
|
+
if (typeof fromRecipient === 'string' && fromRecipient.length > 0) return fromRecipient
|
|
341
|
+
return defaultChannelId
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function pickDiscordMessage(raw: InboundMessage): DiscordMessageObject {
|
|
345
|
+
const candidate = (raw.raw?.discordMessage ?? raw.raw) as DiscordMessageObject | undefined
|
|
346
|
+
if (!candidate || typeof candidate !== 'object' || typeof candidate.id !== 'string') {
|
|
347
|
+
throw new Error('[internal] Discord normalizeInbound requires `raw.discordMessage` to be a Discord message object')
|
|
348
|
+
}
|
|
349
|
+
return candidate
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let cachedAdapter: DiscordChannelAdapter | null = null
|
|
353
|
+
|
|
354
|
+
export function getDiscordChannelAdapter(): DiscordChannelAdapter {
|
|
355
|
+
if (!cachedAdapter) cachedAdapter = new DiscordChannelAdapter()
|
|
356
|
+
return cachedAdapter
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export { DiscordChannelAdapter }
|