@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,152 @@
|
|
|
1
|
+
import { COMMUNICATION_CHANNELS_QUEUES } from '@open-mercato/core/modules/communication_channels/lib/queue'
|
|
2
|
+
import { setDiscordRestClient, type DiscordRestClient } from '../../lib/discord-rest'
|
|
3
|
+
import type { DispatchableInteraction } from '../../lib/interactions-dispatch'
|
|
4
|
+
import type { InteractionDispatchJobPayload } from '../../lib/interactions-queue'
|
|
5
|
+
import { DiscordInteractionType } from '../../lib/interactions-verify'
|
|
6
|
+
import handle from '../discord-interactions'
|
|
7
|
+
|
|
8
|
+
const enqueue = jest.fn(async () => {})
|
|
9
|
+
|
|
10
|
+
jest.mock('@open-mercato/core/modules/communication_channels/lib/queue', () => {
|
|
11
|
+
const actual = jest.requireActual('@open-mercato/core/modules/communication_channels/lib/queue')
|
|
12
|
+
return { ...actual, getCommunicationChannelsQueue: () => ({ enqueue }) }
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const interaction: DispatchableInteraction = {
|
|
16
|
+
id: 'interaction-1',
|
|
17
|
+
token: 'interaction-token',
|
|
18
|
+
type: DiscordInteractionType.APPLICATION_COMMAND,
|
|
19
|
+
applicationId: 'app-1',
|
|
20
|
+
discordChannelId: 'discord-channel-1',
|
|
21
|
+
guildId: 'guild-1',
|
|
22
|
+
user: { id: 'user-1', username: 'ada' },
|
|
23
|
+
commandName: 'mercato',
|
|
24
|
+
content: '/mercato message:the printer is jammed',
|
|
25
|
+
timestamp: '2026-06-19T10:00:00.000Z',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function payload(overrides: Partial<InteractionDispatchJobPayload> = {}): InteractionDispatchJobPayload {
|
|
29
|
+
return {
|
|
30
|
+
channelId: 'ch-1',
|
|
31
|
+
channelType: 'discord',
|
|
32
|
+
tenantId: 't-1',
|
|
33
|
+
organizationId: 'o-1',
|
|
34
|
+
credentialScope: { tenantId: 't-1', organizationId: 'o-1', userId: null },
|
|
35
|
+
interaction,
|
|
36
|
+
...overrides,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const credentials = {
|
|
41
|
+
botToken: 'bot-token',
|
|
42
|
+
applicationId: 'app-1',
|
|
43
|
+
publicKey: 'ab'.repeat(32),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function context(resolved: Record<string, unknown> | null = credentials) {
|
|
47
|
+
const resolve = jest.fn(async () => resolved)
|
|
48
|
+
return {
|
|
49
|
+
resolve: <T = unknown>(name: string): T => {
|
|
50
|
+
if (name === 'integrationCredentialsService') return { resolve } as unknown as T
|
|
51
|
+
throw new Error(`[internal] unexpected DI key ${name}`)
|
|
52
|
+
},
|
|
53
|
+
credentialsResolve: resolve,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function restClient(overrides: Partial<DiscordRestClient> = {}) {
|
|
58
|
+
const client = {
|
|
59
|
+
editOriginalInteractionResponse: jest.fn(async () => {}),
|
|
60
|
+
createInteractionFollowUp: jest.fn(async () => {}),
|
|
61
|
+
...overrides,
|
|
62
|
+
}
|
|
63
|
+
setDiscordRestClient(client as unknown as DiscordRestClient)
|
|
64
|
+
return client
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
enqueue.mockClear()
|
|
69
|
+
setDiscordRestClient(null)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('discord interactions worker', () => {
|
|
73
|
+
it('normalizes the interaction into the hub inbound queue and replaces the deferred ack', async () => {
|
|
74
|
+
const client = restClient()
|
|
75
|
+
const ctx = context()
|
|
76
|
+
|
|
77
|
+
await handle({ payload: payload() } as never, ctx as never)
|
|
78
|
+
|
|
79
|
+
expect(enqueue).toHaveBeenCalledTimes(1)
|
|
80
|
+
expect(enqueue).toHaveBeenCalledWith(
|
|
81
|
+
expect.objectContaining({
|
|
82
|
+
providerKey: 'discord',
|
|
83
|
+
channelId: 'ch-1',
|
|
84
|
+
channelType: 'discord',
|
|
85
|
+
scope: { tenantId: 't-1', organizationId: 'o-1' },
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
expect(COMMUNICATION_CHANNELS_QUEUES.inbound).toBe('communication-channels-inbound')
|
|
89
|
+
|
|
90
|
+
expect(client.editOriginalInteractionResponse).toHaveBeenCalledTimes(1)
|
|
91
|
+
const [applicationId, token, body] = client.editOriginalInteractionResponse.mock.calls[0] as unknown as [
|
|
92
|
+
string,
|
|
93
|
+
string,
|
|
94
|
+
{ content: string; ephemeral?: boolean },
|
|
95
|
+
]
|
|
96
|
+
expect(applicationId).toBe('app-1')
|
|
97
|
+
expect(token).toBe('interaction-token')
|
|
98
|
+
expect(body.ephemeral).toBe(true)
|
|
99
|
+
expect(body.content).toContain('/mercato')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('re-resolves the bot token from the credential store — the payload never carries one', async () => {
|
|
103
|
+
restClient()
|
|
104
|
+
const ctx = context()
|
|
105
|
+
|
|
106
|
+
await handle({ payload: payload() } as never, ctx as never)
|
|
107
|
+
|
|
108
|
+
expect(ctx.credentialsResolve).toHaveBeenCalledWith('channel_discord', {
|
|
109
|
+
tenantId: 't-1',
|
|
110
|
+
organizationId: 'o-1',
|
|
111
|
+
userId: null,
|
|
112
|
+
})
|
|
113
|
+
expect(JSON.stringify(payload())).not.toContain('bot-token')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('still records the interaction in the hub when no credentials can answer it', async () => {
|
|
117
|
+
const client = restClient()
|
|
118
|
+
const ctx = context(null)
|
|
119
|
+
|
|
120
|
+
await handle({ payload: payload() } as never, ctx as never)
|
|
121
|
+
|
|
122
|
+
expect(enqueue).toHaveBeenCalledTimes(1)
|
|
123
|
+
expect(client.editOriginalInteractionResponse).not.toHaveBeenCalled()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('propagates a follow-up failure so the queue retries inside the token lifetime', async () => {
|
|
127
|
+
restClient({
|
|
128
|
+
editOriginalInteractionResponse: jest.fn(async () => {
|
|
129
|
+
throw new Error('discord 500')
|
|
130
|
+
}),
|
|
131
|
+
createInteractionFollowUp: jest.fn(async () => {
|
|
132
|
+
throw new Error('discord 500')
|
|
133
|
+
}),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
await expect(handle({ payload: payload() } as never, context() as never)).rejects.toThrow('discord 500')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('drops a bot-invoked interaction from the hub but still answers it', async () => {
|
|
140
|
+
const client = restClient()
|
|
141
|
+
|
|
142
|
+
await handle(
|
|
143
|
+
{
|
|
144
|
+
payload: payload({ interaction: { ...interaction, user: { id: 'bot-1', username: 'bot', bot: true } } }),
|
|
145
|
+
} as never,
|
|
146
|
+
context() as never,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
expect(enqueue).not.toHaveBeenCalled()
|
|
150
|
+
expect(client.editOriginalInteractionResponse).toHaveBeenCalledTimes(1)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { QueuedJob, WorkerMeta } from '@open-mercato/queue'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
6
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
7
|
+
import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
|
|
8
|
+
import {
|
|
9
|
+
COMMUNICATION_CHANNELS_QUEUES,
|
|
10
|
+
getCommunicationChannelsQueue,
|
|
11
|
+
} from '@open-mercato/core/modules/communication_channels/lib/queue'
|
|
12
|
+
import { emitCommunicationChannelsEvent } from '@open-mercato/core/modules/communication_channels/events'
|
|
13
|
+
import { parseDiscordCredentialsOrThrow, discordChannelStateSchema } from '../lib/credentials'
|
|
14
|
+
import {
|
|
15
|
+
getDiscordGatewayClient,
|
|
16
|
+
type DiscordGatewayHandle,
|
|
17
|
+
type GatewayResumeState,
|
|
18
|
+
} from '../lib/discord-gateway-client'
|
|
19
|
+
import {
|
|
20
|
+
persistDiscordChannelState,
|
|
21
|
+
quarantineDiscordChannel,
|
|
22
|
+
type DiscordChannelScope,
|
|
23
|
+
type DiscordChannelStatePatch,
|
|
24
|
+
} from '../lib/channel-state-store'
|
|
25
|
+
import { buildInboundMessageJob, buildReactionJob, type GatewayChannelScope } from '../lib/gateway-bridge'
|
|
26
|
+
|
|
27
|
+
const logger = createLogger('channel_discord').child({ component: 'gateway-worker' })
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Long-running Discord Gateway bridge worker (SPEC 2026-06-19 § Gateway worker).
|
|
31
|
+
*
|
|
32
|
+
* This is a *provider-owned* long-running worker — a novel pattern relative to
|
|
33
|
+
* the email providers (which are poll/push-driven by the hub). A worker file
|
|
34
|
+
* under `workers/` is auto-discovered, so shipping one from a provider package is
|
|
35
|
+
* allowed by the framework. `concurrency: 1` enforces the single-identify-per-bot
|
|
36
|
+
* discipline Discord requires.
|
|
37
|
+
*
|
|
38
|
+
* The job opens one Gateway WebSocket per active `discord` channel and bridges
|
|
39
|
+
* `MESSAGE_CREATE` / reaction events into the hub's existing queues (inbound +
|
|
40
|
+
* reactions) — the same jobs the webhook route enqueues, so the hub stays
|
|
41
|
+
* unchanged. Events the bot authored are dropped (feedback-loop guard); the hub
|
|
42
|
+
* dedups the rest.
|
|
43
|
+
*
|
|
44
|
+
* Set `OM_CHANNEL_DISCORD_GATEWAY_DISABLED=1` to skip opening sockets (CI /
|
|
45
|
+
* send-only deployments).
|
|
46
|
+
*/
|
|
47
|
+
/** Queue that the gateway bridge consumes. Shared with `cli.ts` (start-gateway). */
|
|
48
|
+
export const CHANNEL_DISCORD_GATEWAY_QUEUE = 'channel_discord_gateway'
|
|
49
|
+
|
|
50
|
+
export const metadata: WorkerMeta = {
|
|
51
|
+
queue: CHANNEL_DISCORD_GATEWAY_QUEUE,
|
|
52
|
+
id: 'channel_discord:gateway',
|
|
53
|
+
concurrency: 1,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type HandlerContext = {
|
|
57
|
+
resolve: <T = unknown>(name: string) => T
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type GatewayJobPayload = {
|
|
61
|
+
/** Optional tenant filter; when absent, all active discord channels connect. */
|
|
62
|
+
tenantId?: string
|
|
63
|
+
/**
|
|
64
|
+
* Optional organization filter, narrowing within `tenantId`. Both the channel
|
|
65
|
+
* query and the reconciliation honour it, so a job scoped to one organization
|
|
66
|
+
* never opens, and never tears down, a socket belonging to another.
|
|
67
|
+
*/
|
|
68
|
+
organizationId?: string | null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface GatewayConnectionEntry {
|
|
72
|
+
handle: DiscordGatewayHandle
|
|
73
|
+
tenantId: string
|
|
74
|
+
/** Owning organization, or `null` for a tenant-wide channel. Scopes teardown. */
|
|
75
|
+
organizationId: string | null
|
|
76
|
+
/** SHA-256 of the bot token, never the token itself — see `botTokenFingerprint`. */
|
|
77
|
+
botTokenFingerprint?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stable, non-reversible identity for a bot token, used to detect that two
|
|
82
|
+
* channel rows are really the same Discord bot.
|
|
83
|
+
*
|
|
84
|
+
* The hub derives a channel's `externalIdentifier` by sniffing the credential
|
|
85
|
+
* bag for email-shaped keys, which Discord has none of, so every reconnect
|
|
86
|
+
* inserts a fresh row instead of healing the existing one. Until that is fixed
|
|
87
|
+
* hub-side, two rows for one bot would each open a socket and IDENTIFY
|
|
88
|
+
* independently, defeating the single-identify discipline `concurrency: 1`
|
|
89
|
+
* exists to enforce. Hashing keeps the token out of the registry and the logs.
|
|
90
|
+
*/
|
|
91
|
+
export function botTokenFingerprint(botToken: string): string {
|
|
92
|
+
return createHash('sha256').update(botToken).digest('hex')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether another channel already holds a live session for the same bot.
|
|
97
|
+
* Pure over its arguments so the guard is unit-testable without sockets.
|
|
98
|
+
*/
|
|
99
|
+
export function findChannelWithSameBot(
|
|
100
|
+
channelId: string,
|
|
101
|
+
fingerprint: string,
|
|
102
|
+
connections: Map<string, GatewayConnectionEntry> = activeConnections,
|
|
103
|
+
): string | null {
|
|
104
|
+
for (const [otherId, entry] of connections) {
|
|
105
|
+
if (otherId === channelId) continue
|
|
106
|
+
if (entry.botTokenFingerprint !== fingerprint) continue
|
|
107
|
+
if (!isConnectionLive(entry)) continue
|
|
108
|
+
return otherId
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Module-level registry so a re-run replaces an existing connection instead of
|
|
114
|
+
// opening a second socket for the same channel (single-identify discipline).
|
|
115
|
+
// Keyed by channel id; carries tenantId so a per-tenant reconciliation never
|
|
116
|
+
// tears down another tenant's sockets.
|
|
117
|
+
const activeConnections = new Map<string, GatewayConnectionEntry>()
|
|
118
|
+
|
|
119
|
+
// Serializes resume-state writes per channel. The socket callbacks that produce
|
|
120
|
+
// them (READY on every (re)connect) fire independently of the job loop, so
|
|
121
|
+
// without a chain two callbacks for the same channel could interleave their
|
|
122
|
+
// read-modify-write and lose the fresher one.
|
|
123
|
+
const pendingStateWrites = new Map<string, Promise<unknown>>()
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether a registry entry still holds a running session. A session that is
|
|
127
|
+
* merely reconnecting with backoff after a resumable close reports `true` — it
|
|
128
|
+
* heals itself and MUST NOT be torn down and replaced by the refresh job.
|
|
129
|
+
*/
|
|
130
|
+
export function isConnectionLive(entry: GatewayConnectionEntry | undefined): boolean {
|
|
131
|
+
if (!entry) return false
|
|
132
|
+
try {
|
|
133
|
+
return entry.handle.isActive()
|
|
134
|
+
} catch {
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The scope a reconciliation run is allowed to tear connections down within. */
|
|
140
|
+
export interface GatewayReconcileScope {
|
|
141
|
+
tenantId?: string
|
|
142
|
+
organizationId?: string | null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The channel query for one job payload. Every scope key the payload carries
|
|
147
|
+
* narrows the query: a job scoped to an organization must connect that
|
|
148
|
+
* organization's channels and no others, and the same scope is then handed to
|
|
149
|
+
* the reconciliation so it tears down exactly the set it queried.
|
|
150
|
+
*/
|
|
151
|
+
export function buildGatewayChannelFilter(scope: GatewayReconcileScope): Record<string, unknown> {
|
|
152
|
+
const filter: Record<string, unknown> = { providerKey: 'discord', isActive: true, deletedAt: null }
|
|
153
|
+
if (scope.tenantId) filter.tenantId = scope.tenantId
|
|
154
|
+
if (scope.organizationId != null) filter.organizationId = scope.organizationId
|
|
155
|
+
return filter
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Close + drop any live connection whose channel is no longer in the active set
|
|
160
|
+
* (deactivated / soft-deleted / re-scoped). Without this the socket + heartbeat
|
|
161
|
+
* timer would leak forever after a channel is disconnected. When `scope` names a
|
|
162
|
+
* tenant and/or an organization, only connections inside that scope are eligible
|
|
163
|
+
* for teardown — a scoped refresh reconciles exactly the set it queried and
|
|
164
|
+
* never touches another tenant's or organization's sockets. Returns the ids
|
|
165
|
+
* reconciled away. Pure over its arguments so it is unit-testable.
|
|
166
|
+
*/
|
|
167
|
+
export function reconcileGatewayConnections(
|
|
168
|
+
activeChannelIds: Set<string>,
|
|
169
|
+
connections: Map<string, GatewayConnectionEntry> = activeConnections,
|
|
170
|
+
scope: GatewayReconcileScope = {},
|
|
171
|
+
): string[] {
|
|
172
|
+
const removed: string[] = []
|
|
173
|
+
for (const [channelId, entry] of connections) {
|
|
174
|
+
if (activeChannelIds.has(channelId)) continue
|
|
175
|
+
if (scope.tenantId && entry.tenantId !== scope.tenantId) continue
|
|
176
|
+
if (scope.organizationId != null && entry.organizationId !== scope.organizationId) continue
|
|
177
|
+
try {
|
|
178
|
+
entry.handle.close()
|
|
179
|
+
} catch {
|
|
180
|
+
/* best-effort close */
|
|
181
|
+
}
|
|
182
|
+
connections.delete(channelId)
|
|
183
|
+
removed.push(channelId)
|
|
184
|
+
}
|
|
185
|
+
return removed
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
type CredentialsServiceLike = {
|
|
189
|
+
resolve: (
|
|
190
|
+
integrationId: string,
|
|
191
|
+
scope: { organizationId: string; tenantId: string; userId?: string | null },
|
|
192
|
+
) => Promise<Record<string, unknown> | null>
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export default async function handle(job: QueuedJob<GatewayJobPayload>, ctx: HandlerContext): Promise<void> {
|
|
196
|
+
if (parseBooleanWithDefault(process.env.OM_CHANNEL_DISCORD_GATEWAY_DISABLED, false)) {
|
|
197
|
+
logger.info('gateway disabled via OM_CHANNEL_DISCORD_GATEWAY_DISABLED — skipping connect')
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const em = (ctx.resolve('em') as EntityManager).fork()
|
|
202
|
+
let credentialsService: CredentialsServiceLike | null = null
|
|
203
|
+
try {
|
|
204
|
+
credentialsService = ctx.resolve<CredentialsServiceLike>('integrationCredentialsService')
|
|
205
|
+
} catch {
|
|
206
|
+
credentialsService = null
|
|
207
|
+
}
|
|
208
|
+
if (!credentialsService) {
|
|
209
|
+
logger.warn('integrationCredentialsService unavailable — cannot resolve bot tokens')
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A scoped job narrows the query by every scope key it carries — a payload
|
|
214
|
+
// that names an organization must not silently connect the whole tenant.
|
|
215
|
+
const scope: GatewayReconcileScope = {
|
|
216
|
+
tenantId: job.payload?.tenantId,
|
|
217
|
+
organizationId: job.payload?.organizationId ?? null,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const channels = (await findWithDecryption(
|
|
221
|
+
em,
|
|
222
|
+
CommunicationChannel,
|
|
223
|
+
buildGatewayChannelFilter(scope),
|
|
224
|
+
)) as CommunicationChannel[]
|
|
225
|
+
|
|
226
|
+
// The refresh job is a *reconciler*, not a re-connector: a channel whose
|
|
227
|
+
// session is still running is left alone. Restarting it (the previous
|
|
228
|
+
// behaviour) tore down healthy sockets on every tick of the CLI's 60s refresh
|
|
229
|
+
// and forced a fresh IDENTIFY each time, which burns Discord's session-start
|
|
230
|
+
// budget and drops the dispatches in flight.
|
|
231
|
+
let started = 0
|
|
232
|
+
let kept = 0
|
|
233
|
+
let quarantined = 0
|
|
234
|
+
for (const channel of channels) {
|
|
235
|
+
if (isConnectionLive(activeConnections.get(channel.id))) {
|
|
236
|
+
kept += 1
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
// A channel Discord fatally rejected (bad token / disallowed intents) stays
|
|
240
|
+
// parked until an operator reconnects it. Retrying cannot fix either cause,
|
|
241
|
+
// and re-IDENTIFYing every tick burns the bot's daily session-start budget.
|
|
242
|
+
if (channel.status === 'requires_reauth') {
|
|
243
|
+
quarantined += 1
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
await startChannelConnection(channel, credentialsService, em)
|
|
247
|
+
started += 1
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Full reconciliation: close sockets for channels that dropped out of the
|
|
251
|
+
// active set since the last run (deactivated / soft-deleted). A scoped run
|
|
252
|
+
// reconciles within exactly the scope it queried, so a per-tenant or
|
|
253
|
+
// per-organization refresh never tears down sockets it did not consider.
|
|
254
|
+
const activeIds = new Set(channels.map((channel) => channel.id))
|
|
255
|
+
const removed = reconcileGatewayConnections(activeIds, activeConnections, scope)
|
|
256
|
+
if (removed.length > 0) {
|
|
257
|
+
logger.info('reconciled away stale discord gateway connections', { channelIds: removed })
|
|
258
|
+
}
|
|
259
|
+
logger.debug('discord gateway reconciliation finished', { started, kept, quarantined, removed: removed.length })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function startChannelConnection(
|
|
263
|
+
channel: CommunicationChannel,
|
|
264
|
+
credentialsService: CredentialsServiceLike,
|
|
265
|
+
em: EntityManager,
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
const scope: GatewayChannelScope = {
|
|
268
|
+
channelId: channel.id,
|
|
269
|
+
channelType: channel.channelType,
|
|
270
|
+
tenantId: channel.tenantId,
|
|
271
|
+
organizationId: channel.organizationId ?? null,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let credentials: Record<string, unknown> | null = null
|
|
275
|
+
try {
|
|
276
|
+
credentials = await credentialsService.resolve('channel_discord', {
|
|
277
|
+
tenantId: channel.tenantId,
|
|
278
|
+
organizationId: channel.organizationId ?? channel.tenantId,
|
|
279
|
+
userId: channel.userId ?? null,
|
|
280
|
+
})
|
|
281
|
+
} catch (err) {
|
|
282
|
+
logger.warn('failed to resolve discord credentials for channel', { channelId: channel.id, err })
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
if (!credentials) return
|
|
286
|
+
|
|
287
|
+
let botToken: string
|
|
288
|
+
try {
|
|
289
|
+
botToken = parseDiscordCredentialsOrThrow(credentials).botToken
|
|
290
|
+
} catch (err) {
|
|
291
|
+
logger.warn('invalid discord credentials for channel', { channelId: channel.id, err })
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const fingerprint = botTokenFingerprint(botToken)
|
|
296
|
+
const duplicateOf = findChannelWithSameBot(channel.id, fingerprint)
|
|
297
|
+
if (duplicateOf) {
|
|
298
|
+
logger.warn('skipping discord gateway connect — another channel already serves this bot', {
|
|
299
|
+
channelId: channel.id,
|
|
300
|
+
servedBy: duplicateOf,
|
|
301
|
+
})
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const channelState = discordChannelStateSchema.parse(channel.channelState ?? {})
|
|
306
|
+
const resumeState: GatewayResumeState = {
|
|
307
|
+
sessionId: channelState.sessionId,
|
|
308
|
+
sequence: channelState.sequence ?? null,
|
|
309
|
+
resumeGatewayUrl: channelState.resumeGatewayUrl,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Only reached for a channel with no live session (see `handle`): drop the
|
|
313
|
+
// dead handle, if any, before opening its replacement.
|
|
314
|
+
const stale = activeConnections.get(channel.id)
|
|
315
|
+
if (stale) {
|
|
316
|
+
try {
|
|
317
|
+
stale.handle.close()
|
|
318
|
+
} catch {
|
|
319
|
+
/* best-effort close of an already-dead session */
|
|
320
|
+
}
|
|
321
|
+
activeConnections.delete(channel.id)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const stateScope: DiscordChannelScope = {
|
|
325
|
+
tenantId: channel.tenantId,
|
|
326
|
+
organizationId: channel.organizationId ?? null,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const inboundQueue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.inbound)
|
|
330
|
+
const reactionsQueue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.reactions)
|
|
331
|
+
let botUserId: string | undefined = channelState.botUserId
|
|
332
|
+
|
|
333
|
+
const handle = getDiscordGatewayClient().connect({
|
|
334
|
+
botToken,
|
|
335
|
+
resumeState,
|
|
336
|
+
onMessage: async (message) => {
|
|
337
|
+
// Log receive / drop / enqueue separately. Without these three lines a lost
|
|
338
|
+
// inbound message is indistinguishable from a message correctly dropped by
|
|
339
|
+
// the bot-self guard, and both are indistinguishable from a dead socket —
|
|
340
|
+
// diagnosing it required an external WebSocket probe during QA.
|
|
341
|
+
logger.debug('discord gateway received message', { channelId: channel.id, messageId: message.id })
|
|
342
|
+
const jobPayload = buildInboundMessageJob({ message, channel: scope, botUserId })
|
|
343
|
+
if (!jobPayload) {
|
|
344
|
+
logger.debug('discord inbound message dropped — authored by this bot', {
|
|
345
|
+
channelId: channel.id,
|
|
346
|
+
messageId: message.id,
|
|
347
|
+
})
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
await inboundQueue.enqueue(jobPayload as unknown as Record<string, unknown>)
|
|
351
|
+
logger.debug('discord inbound message enqueued', {
|
|
352
|
+
channelId: channel.id,
|
|
353
|
+
messageId: message.id,
|
|
354
|
+
queue: COMMUNICATION_CHANNELS_QUEUES.inbound,
|
|
355
|
+
})
|
|
356
|
+
},
|
|
357
|
+
onReaction: async (reaction, action) => {
|
|
358
|
+
const reactionJob = await buildReactionJob({ reaction, action, channel: scope, botUserId })
|
|
359
|
+
if (!reactionJob) return
|
|
360
|
+
await reactionsQueue.enqueue(reactionJob as unknown as Record<string, unknown>)
|
|
361
|
+
},
|
|
362
|
+
onReady: async ({ botUserId: readyBotUserId, resumeState: freshResumeState }) => {
|
|
363
|
+
botUserId = readyBotUserId || botUserId
|
|
364
|
+
await persistChannelState(em, channel.id, stateScope, { ...freshResumeState, botUserId })
|
|
365
|
+
logger.info('discord gateway ready', { channelId: channel.id })
|
|
366
|
+
},
|
|
367
|
+
onRequiresReauth: async ({ code }) => {
|
|
368
|
+
logger.warn('discord gateway fatal close — quarantining channel', { channelId: channel.id, code })
|
|
369
|
+
// Persist the status BEFORE dropping the handle. Emitting the event alone
|
|
370
|
+
// is not enough: nothing subscribes to it to change channel state, so the
|
|
371
|
+
// next reconciliation tick would see "no live session" and IDENTIFY again,
|
|
372
|
+
// forever, against a token or intent configuration that cannot self-heal.
|
|
373
|
+
await quarantineChannel(em, channel.id, stateScope, `gateway_close_${code}`)
|
|
374
|
+
await emitCommunicationChannelsEvent(
|
|
375
|
+
'communication_channels.channel.requires_reauth',
|
|
376
|
+
{
|
|
377
|
+
channelId: channel.id,
|
|
378
|
+
providerKey: 'discord',
|
|
379
|
+
channelType: channel.channelType,
|
|
380
|
+
reason: `gateway_close_${code}`,
|
|
381
|
+
tenantId: channel.tenantId,
|
|
382
|
+
organizationId: channel.organizationId ?? null,
|
|
383
|
+
},
|
|
384
|
+
{ persistent: true },
|
|
385
|
+
)
|
|
386
|
+
activeConnections.delete(channel.id)
|
|
387
|
+
},
|
|
388
|
+
})
|
|
389
|
+
activeConnections.set(channel.id, {
|
|
390
|
+
handle,
|
|
391
|
+
tenantId: channel.tenantId,
|
|
392
|
+
organizationId: channel.organizationId ?? null,
|
|
393
|
+
botTokenFingerprint: fingerprint,
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Persist gateway resume state for one channel, tenant-scoped and serialized.
|
|
399
|
+
*
|
|
400
|
+
* The write is queued behind any write already in flight for the same channel so
|
|
401
|
+
* two socket callbacks cannot interleave their read-modify-write, and it runs
|
|
402
|
+
* through `persistDiscordChannelState`, which filters by `tenant_id` /
|
|
403
|
+
* `organization_id` and only merges the gateway-owned keys.
|
|
404
|
+
*/
|
|
405
|
+
async function persistChannelState(
|
|
406
|
+
em: EntityManager,
|
|
407
|
+
channelId: string,
|
|
408
|
+
scope: DiscordChannelScope,
|
|
409
|
+
patch: DiscordChannelStatePatch,
|
|
410
|
+
): Promise<void> {
|
|
411
|
+
const previous = pendingStateWrites.get(channelId) ?? Promise.resolve()
|
|
412
|
+
const next = previous
|
|
413
|
+
.catch(() => undefined)
|
|
414
|
+
.then(async () => {
|
|
415
|
+
try {
|
|
416
|
+
const result = await persistDiscordChannelState({ em, channelId, scope, patch })
|
|
417
|
+
if (result === 'not_found') {
|
|
418
|
+
logger.warn('discord channel not found in scope while persisting resume state', { channelId })
|
|
419
|
+
}
|
|
420
|
+
} catch (err) {
|
|
421
|
+
// Resume-state persistence is best-effort — a failure just means the next
|
|
422
|
+
// connect re-identifies fresh instead of resuming.
|
|
423
|
+
logger.warn('failed to persist discord gateway resume state', { channelId, err })
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
pendingStateWrites.set(channelId, next)
|
|
427
|
+
await next
|
|
428
|
+
if (pendingStateWrites.get(channelId) === next) pendingStateWrites.delete(channelId)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Park a fatally-rejected channel, serialized behind the same per-channel write
|
|
433
|
+
* chain as the resume-state writes so a quarantine and a late `READY` callback
|
|
434
|
+
* cannot interleave their read-modify-write on the same row.
|
|
435
|
+
*/
|
|
436
|
+
async function quarantineChannel(
|
|
437
|
+
em: EntityManager,
|
|
438
|
+
channelId: string,
|
|
439
|
+
scope: DiscordChannelScope,
|
|
440
|
+
reason: string,
|
|
441
|
+
): Promise<void> {
|
|
442
|
+
const previous = pendingStateWrites.get(channelId) ?? Promise.resolve()
|
|
443
|
+
const next = previous
|
|
444
|
+
.catch(() => undefined)
|
|
445
|
+
.then(async () => {
|
|
446
|
+
try {
|
|
447
|
+
const result = await quarantineDiscordChannel({ em, channelId, scope, reason })
|
|
448
|
+
if (result === 'not_found') {
|
|
449
|
+
logger.warn('discord channel not found in scope while quarantining', { channelId })
|
|
450
|
+
}
|
|
451
|
+
} catch (err) {
|
|
452
|
+
logger.warn('failed to quarantine discord channel after fatal close', { channelId, reason, err })
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
pendingStateWrites.set(channelId, next)
|
|
456
|
+
await next
|
|
457
|
+
if (pendingStateWrites.get(channelId) === next) pendingStateWrites.delete(channelId)
|
|
458
|
+
}
|