@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,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DISCORD_GATEWAY_INTENTS,
|
|
3
|
+
GatewayOpcode,
|
|
4
|
+
buildIdentifyPayload,
|
|
5
|
+
buildResumePayload,
|
|
6
|
+
buildHeartbeatPayload,
|
|
7
|
+
computeReconnectDelayMs,
|
|
8
|
+
createHeartbeatMonitor,
|
|
9
|
+
isFatalGatewayCloseCode,
|
|
10
|
+
shouldResumeAfterClose,
|
|
11
|
+
getDiscordGatewayClient,
|
|
12
|
+
type DiscordGatewayConnectOptions,
|
|
13
|
+
type DiscordGatewayHandle,
|
|
14
|
+
type GatewayResumeState,
|
|
15
|
+
type GatewayWebSocketLike,
|
|
16
|
+
} from '../discord-gateway-client'
|
|
17
|
+
|
|
18
|
+
describe('discord gateway state-machine helpers', () => {
|
|
19
|
+
it('declares message-content + guild-message intents', () => {
|
|
20
|
+
// GUILDS (1) | GUILD_MESSAGES (512) | GUILD_MESSAGE_REACTIONS (1024) | MESSAGE_CONTENT (32768)
|
|
21
|
+
expect(DISCORD_GATEWAY_INTENTS).toBe(1 | 512 | 1024 | 32768)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('builds an identify payload with the bot token + intents', () => {
|
|
25
|
+
const payload = buildIdentifyPayload('tok', DISCORD_GATEWAY_INTENTS)
|
|
26
|
+
expect(payload.op).toBe(GatewayOpcode.IDENTIFY)
|
|
27
|
+
expect(payload.d.token).toBe('tok')
|
|
28
|
+
expect(payload.d.intents).toBe(DISCORD_GATEWAY_INTENTS)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('builds a resume payload with session + sequence', () => {
|
|
32
|
+
const payload = buildResumePayload('tok', 'sess-1', 42)
|
|
33
|
+
expect(payload.op).toBe(GatewayOpcode.RESUME)
|
|
34
|
+
expect(payload.d).toEqual({ token: 'tok', session_id: 'sess-1', seq: 42 })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('builds a heartbeat payload with the last sequence', () => {
|
|
38
|
+
expect(buildHeartbeatPayload(7)).toEqual({ op: GatewayOpcode.HEARTBEAT, d: 7 })
|
|
39
|
+
expect(buildHeartbeatPayload(null)).toEqual({ op: GatewayOpcode.HEARTBEAT, d: null })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('treats 4004/4014 as fatal (requires reauth)', () => {
|
|
43
|
+
expect(isFatalGatewayCloseCode(4004)).toBe(true)
|
|
44
|
+
expect(isFatalGatewayCloseCode(4014)).toBe(true)
|
|
45
|
+
expect(isFatalGatewayCloseCode(1006)).toBe(false)
|
|
46
|
+
expect(isFatalGatewayCloseCode(undefined)).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('does not resume after fatal / session-invalidating close codes', () => {
|
|
50
|
+
expect(shouldResumeAfterClose(4004)).toBe(false)
|
|
51
|
+
expect(shouldResumeAfterClose(4007)).toBe(false)
|
|
52
|
+
expect(shouldResumeAfterClose(4009)).toBe(false)
|
|
53
|
+
expect(shouldResumeAfterClose(1000)).toBe(false)
|
|
54
|
+
expect(shouldResumeAfterClose(1006)).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('bounds reconnect backoff between base and cap', () => {
|
|
58
|
+
expect(computeReconnectDelayMs(0)).toBeGreaterThanOrEqual(1000)
|
|
59
|
+
expect(computeReconnectDelayMs(0)).toBeLessThanOrEqual(1500)
|
|
60
|
+
expect(computeReconnectDelayMs(100)).toBeLessThanOrEqual(30_000)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('createHeartbeatMonitor (zombie detection)', () => {
|
|
65
|
+
it('sends the first beat and reconnects when the previous beat was never ACKed', () => {
|
|
66
|
+
const monitor = createHeartbeatMonitor()
|
|
67
|
+
expect(monitor.onBeat()).toBe('send') // first beat armed, awaiting ACK
|
|
68
|
+
expect(monitor.onBeat()).toBe('reconnect') // no ACK arrived → zombie
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('keeps sending while ACKs arrive between beats', () => {
|
|
72
|
+
const monitor = createHeartbeatMonitor()
|
|
73
|
+
expect(monitor.onBeat()).toBe('send')
|
|
74
|
+
monitor.onAck()
|
|
75
|
+
expect(monitor.onBeat()).toBe('send')
|
|
76
|
+
monitor.onAck()
|
|
77
|
+
expect(monitor.onBeat()).toBe('send')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('re-arms after reset (fresh reconnect)', () => {
|
|
81
|
+
const monitor = createHeartbeatMonitor()
|
|
82
|
+
expect(monitor.onBeat()).toBe('send')
|
|
83
|
+
expect(monitor.onBeat()).toBe('reconnect')
|
|
84
|
+
monitor.reset()
|
|
85
|
+
expect(monitor.isAcked()).toBe(true)
|
|
86
|
+
expect(monitor.onBeat()).toBe('send')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
type FakeSocket = {
|
|
91
|
+
sent: string[]
|
|
92
|
+
closes: Array<{ code?: number; reason?: string }>
|
|
93
|
+
socket: GatewayWebSocketLike
|
|
94
|
+
emit: (type: 'open' | 'message' | 'close' | 'error', event: unknown) => void
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createFakeSocket(): FakeSocket {
|
|
98
|
+
const listeners: Partial<Record<'open' | 'message' | 'close' | 'error', Array<(event: unknown) => void>>> = {}
|
|
99
|
+
const sent: string[] = []
|
|
100
|
+
const closes: Array<{ code?: number; reason?: string }> = []
|
|
101
|
+
return {
|
|
102
|
+
sent,
|
|
103
|
+
closes,
|
|
104
|
+
socket: {
|
|
105
|
+
send: (data: string) => {
|
|
106
|
+
sent.push(data)
|
|
107
|
+
},
|
|
108
|
+
close: (code?: number, reason?: string) => {
|
|
109
|
+
closes.push({ code, reason })
|
|
110
|
+
},
|
|
111
|
+
addEventListener: (type, listener) => {
|
|
112
|
+
const bucket = listeners[type] ?? []
|
|
113
|
+
bucket.push(listener)
|
|
114
|
+
listeners[type] = bucket
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
emit: (type, event) => {
|
|
118
|
+
for (const listener of listeners[type] ?? []) listener(event)
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function connectWithFakeSockets(
|
|
124
|
+
sockets: FakeSocket[],
|
|
125
|
+
overrides: Partial<DiscordGatewayConnectOptions> = {},
|
|
126
|
+
): { handle: DiscordGatewayHandle; resumeStates: GatewayResumeState[] } {
|
|
127
|
+
const resumeStates: GatewayResumeState[] = []
|
|
128
|
+
let index = 0
|
|
129
|
+
const handle = getDiscordGatewayClient().connect({
|
|
130
|
+
botToken: 'tok',
|
|
131
|
+
resumeState: { sessionId: 'sess-1', sequence: 5, resumeGatewayUrl: 'wss://resume.example' },
|
|
132
|
+
onMessage: jest.fn(),
|
|
133
|
+
onReaction: jest.fn(),
|
|
134
|
+
onReady: jest.fn(),
|
|
135
|
+
onRequiresReauth: jest.fn(),
|
|
136
|
+
onResumeStateChange: (state) => {
|
|
137
|
+
resumeStates.push(state)
|
|
138
|
+
},
|
|
139
|
+
webSocketFactory: () => {
|
|
140
|
+
const next = sockets[Math.min(index, sockets.length - 1)]
|
|
141
|
+
index += 1
|
|
142
|
+
return next.socket
|
|
143
|
+
},
|
|
144
|
+
...overrides,
|
|
145
|
+
})
|
|
146
|
+
return { handle, resumeStates }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function firstOpcode(sent: string[]): number | undefined {
|
|
150
|
+
const payload = sent.map((raw) => JSON.parse(raw) as { op: number }).find((entry) => entry.op !== GatewayOpcode.HEARTBEAT)
|
|
151
|
+
return payload?.op
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe('gateway session — INVALID_SESSION (opcode 9)', () => {
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
jest.useFakeTimers()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
afterEach(() => {
|
|
160
|
+
jest.clearAllTimers()
|
|
161
|
+
jest.useRealTimers()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('closes the socket so the connection reconnects instead of going deaf', () => {
|
|
165
|
+
const socket = createFakeSocket()
|
|
166
|
+
const { handle } = connectWithFakeSockets([socket])
|
|
167
|
+
|
|
168
|
+
socket.emit('message', { data: JSON.stringify({ op: GatewayOpcode.INVALID_SESSION, d: false }) })
|
|
169
|
+
|
|
170
|
+
expect(socket.closes).toEqual([{ code: 4000, reason: 'invalid session' }])
|
|
171
|
+
handle.close()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('re-IDENTIFYs on the next connect when Discord reports the session is not resumable', () => {
|
|
175
|
+
const first = createFakeSocket()
|
|
176
|
+
const second = createFakeSocket()
|
|
177
|
+
const { handle, resumeStates } = connectWithFakeSockets([first, second])
|
|
178
|
+
|
|
179
|
+
first.emit('message', { data: JSON.stringify({ op: GatewayOpcode.INVALID_SESSION, d: false }) })
|
|
180
|
+
expect(resumeStates.at(-1)).toEqual({ sessionId: undefined, sequence: null, resumeGatewayUrl: undefined })
|
|
181
|
+
|
|
182
|
+
first.emit('close', { code: 4000 })
|
|
183
|
+
jest.advanceTimersByTime(30_000)
|
|
184
|
+
second.emit('message', { data: JSON.stringify({ op: GatewayOpcode.HELLO, d: { heartbeat_interval: 45_000 } }) })
|
|
185
|
+
|
|
186
|
+
expect(firstOpcode(second.sent)).toBe(GatewayOpcode.IDENTIFY)
|
|
187
|
+
handle.close()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('honours the resumable flag and RESUMEs the stored session when Discord allows it', () => {
|
|
191
|
+
const first = createFakeSocket()
|
|
192
|
+
const second = createFakeSocket()
|
|
193
|
+
const { handle, resumeStates } = connectWithFakeSockets([first, second])
|
|
194
|
+
|
|
195
|
+
first.emit('message', { data: JSON.stringify({ op: GatewayOpcode.INVALID_SESSION, d: true }) })
|
|
196
|
+
expect(resumeStates).toEqual([])
|
|
197
|
+
|
|
198
|
+
first.emit('close', { code: 4000 })
|
|
199
|
+
jest.advanceTimersByTime(30_000)
|
|
200
|
+
second.emit('message', { data: JSON.stringify({ op: GatewayOpcode.HELLO, d: { heartbeat_interval: 45_000 } }) })
|
|
201
|
+
|
|
202
|
+
const resume = second.sent.map((raw) => JSON.parse(raw) as { op: number; d?: { session_id?: string } })
|
|
203
|
+
expect(firstOpcode(second.sent)).toBe(GatewayOpcode.RESUME)
|
|
204
|
+
expect(resume.find((entry) => entry.op === GatewayOpcode.RESUME)?.d?.session_id).toBe('sess-1')
|
|
205
|
+
handle.close()
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('gateway handle — isActive()', () => {
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
jest.useFakeTimers()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
afterEach(() => {
|
|
215
|
+
jest.clearAllTimers()
|
|
216
|
+
jest.useRealTimers()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('stays active while the session reconnects, so a refresh never restarts it', () => {
|
|
220
|
+
const first = createFakeSocket()
|
|
221
|
+
const second = createFakeSocket()
|
|
222
|
+
const { handle } = connectWithFakeSockets([first, second])
|
|
223
|
+
|
|
224
|
+
expect(handle.isActive()).toBe(true)
|
|
225
|
+
first.emit('close', { code: 1006 })
|
|
226
|
+
expect(handle.isActive()).toBe(true)
|
|
227
|
+
|
|
228
|
+
jest.advanceTimersByTime(30_000)
|
|
229
|
+
handle.close()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('reports inactive after close() and after a fatal close code', () => {
|
|
233
|
+
const stopped = createFakeSocket()
|
|
234
|
+
const { handle: stoppedHandle } = connectWithFakeSockets([stopped])
|
|
235
|
+
stoppedHandle.close()
|
|
236
|
+
expect(stoppedHandle.isActive()).toBe(false)
|
|
237
|
+
|
|
238
|
+
const fatal = createFakeSocket()
|
|
239
|
+
const { handle: fatalHandle } = connectWithFakeSockets([fatal])
|
|
240
|
+
fatal.emit('close', { code: 4004 })
|
|
241
|
+
expect(fatalHandle.isActive()).toBe(false)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describeAgentFailure, redactSecrets } from '../failure-reason'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Token-shaped fixtures are assembled at run time rather than written as
|
|
5
|
+
* literals. They are fabricated, but a literal in this shape trips GitHub's
|
|
6
|
+
* secret-scanning push protection, and a test file that cannot be pushed is
|
|
7
|
+
* worse than one that spends a `join` on its fixtures.
|
|
8
|
+
*/
|
|
9
|
+
const fakeBotToken = ['MTIzNDU2Nzg5MDEyMzQ1Njc4', 'GhIjKl', 'mNoPqRsTuVwXyZ0123456789abcd'].join('.')
|
|
10
|
+
const fakeJwt = ['eyJhbGciOiJIUzI1NiJ9', 'eyJzdWIiOiIxIn0', 'dBjftJeZ4CVPmB92K27uhbUJU1p1r5wW1gFWFOEjXk'].join('.')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The auto-reply failure marker is persisted on `channelState` and rendered on
|
|
14
|
+
* the settings page, so an upstream error message stops being a log line and
|
|
15
|
+
* becomes product data. These cases pin the two properties that matter: the
|
|
16
|
+
* reason stays useful, and nothing that looks like a credential survives into the
|
|
17
|
+
* column.
|
|
18
|
+
*/
|
|
19
|
+
describe('redactSecrets', () => {
|
|
20
|
+
it('leaves an ordinary diagnostic alone', () => {
|
|
21
|
+
expect(redactSecrets('agent policy denied: missing customers.view')).toBe(
|
|
22
|
+
'agent policy denied: missing customers.view',
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('strips a bot token quoted in an Authorization header', () => {
|
|
27
|
+
const redacted = redactSecrets(`Discord API 401: Authorization: Bot ${fakeBotToken}`)
|
|
28
|
+
expect(redacted).not.toContain(fakeBotToken)
|
|
29
|
+
expect(redacted).toContain('[redacted]')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('strips a bare token-shaped value with no header around it', () => {
|
|
33
|
+
expect(redactSecrets(`rejected ${fakeJwt}`)).toBe('rejected [redacted]')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('strips a provider api key prefix', () => {
|
|
37
|
+
expect(redactSecrets('bad key sk-abcdefghijklmnopqrstuvwxyz')).toBe('bad key [redacted]')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('keeps the query parameter name but not its value', () => {
|
|
41
|
+
expect(redactSecrets('GET https://example.test/x?access_token=abc123&page=2')).toBe(
|
|
42
|
+
'GET https://example.test/x?access_token=[redacted]&page=2',
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('describeAgentFailure', () => {
|
|
48
|
+
it('names the agent, because that is what the operator has to change', () => {
|
|
49
|
+
expect(describeAgentFailure('customers.support', new Error('agent_features_denied'))).toBe(
|
|
50
|
+
'agent customers.support: agent_features_denied',
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('keeps only the first line, so a stack trace never lands in the column', () => {
|
|
55
|
+
const err = new Error('boom\n at somewhere (file.ts:1:1)\n at elsewhere (file.ts:2:2)')
|
|
56
|
+
expect(describeAgentFailure('a.b', err)).toBe('agent a.b: boom')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('survives a thrown non-Error', () => {
|
|
60
|
+
expect(describeAgentFailure('a.b', 'plain string')).toBe('agent a.b: plain string')
|
|
61
|
+
expect(describeAgentFailure('a.b', new Error(''))).toBe('agent a.b: unknown error')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('redacts a credential the upstream error echoed back', () => {
|
|
65
|
+
const err = new Error('POST /channels/1/messages failed: Bearer sk-0123456789abcdefghij')
|
|
66
|
+
expect(describeAgentFailure('a.b', err)).not.toContain('0123456789abcdefghij')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { buildInboundMessageJob, buildReactionJob, type GatewayChannelScope } from '../gateway-bridge'
|
|
2
|
+
import type { DiscordMessageObject } from '../discord-rest'
|
|
3
|
+
|
|
4
|
+
const channel: GatewayChannelScope = {
|
|
5
|
+
channelId: 'channel-uuid',
|
|
6
|
+
channelType: 'discord',
|
|
7
|
+
tenantId: 'tenant-uuid',
|
|
8
|
+
organizationId: 'org-uuid',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeMessage(authorId: string): DiscordMessageObject {
|
|
12
|
+
return {
|
|
13
|
+
id: 'm1',
|
|
14
|
+
channel_id: 'disc-chan',
|
|
15
|
+
content: 'hi',
|
|
16
|
+
author: { id: authorId, username: 'u' },
|
|
17
|
+
timestamp: '2026-06-19T10:00:00.000Z',
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('buildInboundMessageJob', () => {
|
|
22
|
+
it('produces a hub inbound job for a non-bot message', () => {
|
|
23
|
+
const job = buildInboundMessageJob({ message: makeMessage('user-1'), channel, botUserId: 'bot-1' })
|
|
24
|
+
expect(job).not.toBeNull()
|
|
25
|
+
expect(job?.providerKey).toBe('discord')
|
|
26
|
+
expect(job?.channelId).toBe('channel-uuid')
|
|
27
|
+
expect(job?.raw.eventType).toBe('message')
|
|
28
|
+
expect(job?.scope).toEqual({ tenantId: 'tenant-uuid', organizationId: 'org-uuid' })
|
|
29
|
+
expect((job?.raw.raw as { discordMessage?: unknown }).discordMessage).toBeTruthy()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns null for the bot own message (feedback-loop guard)', () => {
|
|
33
|
+
const job = buildInboundMessageJob({ message: makeMessage('bot-1'), channel, botUserId: 'bot-1' })
|
|
34
|
+
expect(job).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('buildReactionJob', () => {
|
|
39
|
+
it('builds a normalized reaction job with mapped emoji', async () => {
|
|
40
|
+
const job = await buildReactionJob({
|
|
41
|
+
reaction: { message_id: 'm1', channel_id: 'disc-chan', user_id: 'user-2', emoji: { name: '👍', id: null } },
|
|
42
|
+
action: 'added',
|
|
43
|
+
channel,
|
|
44
|
+
botUserId: 'bot-1',
|
|
45
|
+
})
|
|
46
|
+
expect(job).not.toBeNull()
|
|
47
|
+
expect(job?.kind).toBe('inbound')
|
|
48
|
+
expect(job?.event.emoji).toBe('👍')
|
|
49
|
+
expect(job?.event.action).toBe('added')
|
|
50
|
+
expect(job?.event.externalMessageId).toBe('m1')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns null for a reaction the bot itself added', async () => {
|
|
54
|
+
const job = await buildReactionJob({
|
|
55
|
+
reaction: { message_id: 'm1', channel_id: 'disc-chan', user_id: 'bot-1', emoji: { name: '👍' } },
|
|
56
|
+
action: 'added',
|
|
57
|
+
channel,
|
|
58
|
+
botUserId: 'bot-1',
|
|
59
|
+
})
|
|
60
|
+
expect(job).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createTranslator } from '@open-mercato/shared/lib/i18n/translate'
|
|
2
|
+
import type { DiscordRestClient } from '../discord-rest'
|
|
3
|
+
import {
|
|
4
|
+
buildInteractionFollowUpContent,
|
|
5
|
+
buildInteractionInboundJob,
|
|
6
|
+
buildInteractionMessage,
|
|
7
|
+
readDispatchableInteraction,
|
|
8
|
+
renderInteractionContent,
|
|
9
|
+
sendInteractionFollowUp,
|
|
10
|
+
type DispatchableInteraction,
|
|
11
|
+
} from '../interactions-dispatch'
|
|
12
|
+
import { DiscordInteractionType, type ParsedInteraction } from '../interactions-verify'
|
|
13
|
+
import { normalizeInboundDiscordMessage } from '../normalize-inbound'
|
|
14
|
+
|
|
15
|
+
const now = new Date('2026-06-19T10:00:00.000Z')
|
|
16
|
+
|
|
17
|
+
function slashCommand(overrides: Partial<ParsedInteraction> = {}): ParsedInteraction {
|
|
18
|
+
return {
|
|
19
|
+
type: DiscordInteractionType.APPLICATION_COMMAND,
|
|
20
|
+
id: 'interaction-1',
|
|
21
|
+
token: 'interaction-token',
|
|
22
|
+
application_id: 'app-1',
|
|
23
|
+
channel_id: 'discord-channel-1',
|
|
24
|
+
guild_id: 'guild-1',
|
|
25
|
+
member: { user: { id: 'user-1', username: 'ada', global_name: 'Ada L.' } },
|
|
26
|
+
data: { name: 'mercato', options: [{ name: 'message', value: 'the printer is jammed' }] },
|
|
27
|
+
...overrides,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('renderInteractionContent', () => {
|
|
32
|
+
it('renders a slash command with its options', () => {
|
|
33
|
+
expect(renderInteractionContent(slashCommand())).toBe('/mercato message:the printer is jammed')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('descends into subcommands instead of losing what the user typed', () => {
|
|
37
|
+
// Discord nests the real arguments under the subcommand's own `options`, so
|
|
38
|
+
// a one-level read renders `/support ticket:` and drops the payload.
|
|
39
|
+
const content = renderInteractionContent(
|
|
40
|
+
slashCommand({
|
|
41
|
+
data: {
|
|
42
|
+
name: 'support',
|
|
43
|
+
options: [
|
|
44
|
+
{ name: 'ticket', options: [{ name: 'subject', value: 'jam' }, { name: 'priority', value: 2 }] },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
expect(content).toBe('/support ticket subject:jam priority:2')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('renders a component press with its selected values', () => {
|
|
53
|
+
const content = renderInteractionContent({
|
|
54
|
+
type: DiscordInteractionType.MESSAGE_COMPONENT,
|
|
55
|
+
data: { custom_id: 'escalate', values: ['tier-2', 'urgent'] },
|
|
56
|
+
})
|
|
57
|
+
expect(content).toBe('[component] escalate tier-2 urgent')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('renders a modal submission field by field', () => {
|
|
61
|
+
const content = renderInteractionContent({
|
|
62
|
+
type: DiscordInteractionType.MODAL_SUBMIT,
|
|
63
|
+
data: {
|
|
64
|
+
custom_id: 'ticket-form',
|
|
65
|
+
components: [{ components: [{ custom_id: 'subject', value: 'jam' }] }],
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
expect(content).toBe('[modal] ticket-form subject:jam')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('readDispatchableInteraction', () => {
|
|
73
|
+
it('extracts everything the worker needs from a guild slash command', () => {
|
|
74
|
+
const dispatch = readDispatchableInteraction(slashCommand(), { now })
|
|
75
|
+
expect(dispatch).toEqual({
|
|
76
|
+
id: 'interaction-1',
|
|
77
|
+
token: 'interaction-token',
|
|
78
|
+
type: DiscordInteractionType.APPLICATION_COMMAND,
|
|
79
|
+
applicationId: 'app-1',
|
|
80
|
+
discordChannelId: 'discord-channel-1',
|
|
81
|
+
guildId: 'guild-1',
|
|
82
|
+
user: { id: 'user-1', username: 'ada', global_name: 'Ada L.' },
|
|
83
|
+
commandName: 'mercato',
|
|
84
|
+
customId: undefined,
|
|
85
|
+
content: '/mercato message:the printer is jammed',
|
|
86
|
+
timestamp: now.toISOString(),
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('reads the invoker from `user` in a DM, where there is no `member`', () => {
|
|
91
|
+
const dispatch = readDispatchableInteraction(
|
|
92
|
+
slashCommand({ member: undefined, guild_id: undefined, user: { id: 'user-2', username: 'grace' } }),
|
|
93
|
+
{ now },
|
|
94
|
+
)
|
|
95
|
+
expect(dispatch?.user.id).toBe('user-2')
|
|
96
|
+
expect(dispatch?.guildId).toBeUndefined()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it.each([
|
|
100
|
+
['no follow-up token', { token: undefined }],
|
|
101
|
+
['no interaction id', { id: undefined }],
|
|
102
|
+
['no channel', { channel_id: undefined }],
|
|
103
|
+
['no application', { application_id: undefined }],
|
|
104
|
+
['no invoking user', { member: undefined, user: undefined }],
|
|
105
|
+
])('refuses to dispatch a payload with %s', (_label, overrides) => {
|
|
106
|
+
expect(readDispatchableInteraction(slashCommand(overrides as Partial<ParsedInteraction>), { now })).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('refuses interaction types this provider does not turn into hub messages', () => {
|
|
110
|
+
expect(readDispatchableInteraction(slashCommand({ type: DiscordInteractionType.PING }), { now })).toBeNull()
|
|
111
|
+
expect(
|
|
112
|
+
readDispatchableInteraction(
|
|
113
|
+
slashCommand({ type: DiscordInteractionType.APPLICATION_COMMAND_AUTOCOMPLETE }),
|
|
114
|
+
{ now },
|
|
115
|
+
),
|
|
116
|
+
).toBeNull()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('buildInteractionInboundJob', () => {
|
|
121
|
+
const channel = { channelId: 'ch-1', channelType: 'discord', tenantId: 't-1', organizationId: 'o-1' }
|
|
122
|
+
|
|
123
|
+
it('produces the same job shape the gateway worker enqueues, under the matched tenant scope', () => {
|
|
124
|
+
const dispatch = readDispatchableInteraction(slashCommand(), { now }) as DispatchableInteraction
|
|
125
|
+
const job = buildInteractionInboundJob({ dispatch, channel })
|
|
126
|
+
|
|
127
|
+
expect(job).toMatchObject({
|
|
128
|
+
providerKey: 'discord',
|
|
129
|
+
channelId: 'ch-1',
|
|
130
|
+
channelType: 'discord',
|
|
131
|
+
scope: { tenantId: 't-1', organizationId: 'o-1' },
|
|
132
|
+
})
|
|
133
|
+
expect(job?.raw.eventType).toBe('message')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('normalizes through the adapter path into a hub message keyed on the interaction id', () => {
|
|
137
|
+
// The point of synthesizing a message object is that the hub's existing
|
|
138
|
+
// inbound path handles it unchanged — so drive the real normalizer.
|
|
139
|
+
const dispatch = readDispatchableInteraction(slashCommand(), { now }) as DispatchableInteraction
|
|
140
|
+
const normalized = normalizeInboundDiscordMessage(buildInteractionMessage(dispatch))
|
|
141
|
+
|
|
142
|
+
expect(normalized.externalMessageId).toBe('interaction-1')
|
|
143
|
+
expect(normalized.externalConversationId).toBe('discord-channel:discord-channel-1')
|
|
144
|
+
expect(normalized.senderIdentifier).toBe('user-1')
|
|
145
|
+
expect(normalized.body).toBe('/mercato message:the printer is jammed')
|
|
146
|
+
expect(normalized.channelMetadata).toMatchObject({
|
|
147
|
+
discordInteractionId: 'interaction-1',
|
|
148
|
+
discordInteractionType: DiscordInteractionType.APPLICATION_COMMAND,
|
|
149
|
+
discordCommandName: 'mercato',
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('drops an interaction invoked by a bot — the same feedback-loop guard as inbound messages', () => {
|
|
154
|
+
const dispatch = readDispatchableInteraction(
|
|
155
|
+
slashCommand({ member: { user: { id: 'bot-1', username: 'other-bot' } } }),
|
|
156
|
+
{ now },
|
|
157
|
+
) as DispatchableInteraction
|
|
158
|
+
const botAuthored = { ...dispatch, user: { ...dispatch.user, bot: true } }
|
|
159
|
+
expect(buildInteractionInboundJob({ dispatch: botAuthored, channel })).toBeNull()
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('sendInteractionFollowUp', () => {
|
|
164
|
+
function restClient(overrides: Partial<DiscordRestClient>): DiscordRestClient {
|
|
165
|
+
return {
|
|
166
|
+
editOriginalInteractionResponse: jest.fn(async () => {}),
|
|
167
|
+
createInteractionFollowUp: jest.fn(async () => {}),
|
|
168
|
+
...overrides,
|
|
169
|
+
} as unknown as DiscordRestClient
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const input = { applicationId: 'app-1', interactionToken: 'tok', content: 'done', ephemeral: true }
|
|
173
|
+
|
|
174
|
+
it('edits the original response, because that is what ends the "thinking" state', async () => {
|
|
175
|
+
const client = restClient({})
|
|
176
|
+
await expect(sendInteractionFollowUp(client, input)).resolves.toBe('edited-original')
|
|
177
|
+
expect(client.editOriginalInteractionResponse).toHaveBeenCalledWith('app-1', 'tok', {
|
|
178
|
+
content: 'done',
|
|
179
|
+
ephemeral: true,
|
|
180
|
+
})
|
|
181
|
+
expect(client.createInteractionFollowUp).not.toHaveBeenCalled()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('falls back to a follow-up message when the original response is gone', async () => {
|
|
185
|
+
const client = restClient({
|
|
186
|
+
editOriginalInteractionResponse: jest.fn(async () => {
|
|
187
|
+
throw new Error('404 Unknown Webhook')
|
|
188
|
+
}),
|
|
189
|
+
})
|
|
190
|
+
await expect(sendInteractionFollowUp(client, input)).resolves.toBe('follow-up')
|
|
191
|
+
expect(client.createInteractionFollowUp).toHaveBeenCalledTimes(1)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('rethrows when neither call delivers, so the queue retries inside the token lifetime', async () => {
|
|
195
|
+
const client = restClient({
|
|
196
|
+
editOriginalInteractionResponse: jest.fn(async () => {
|
|
197
|
+
throw new Error('edit failed')
|
|
198
|
+
}),
|
|
199
|
+
createInteractionFollowUp: jest.fn(async () => {
|
|
200
|
+
throw new Error('follow-up failed')
|
|
201
|
+
}),
|
|
202
|
+
})
|
|
203
|
+
await expect(sendInteractionFollowUp(client, input)).rejects.toThrow('edit failed')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('buildInteractionFollowUpContent', () => {
|
|
208
|
+
it('names the command it recorded', () => {
|
|
209
|
+
const dispatch = readDispatchableInteraction(slashCommand(), { now }) as DispatchableInteraction
|
|
210
|
+
const content = buildInteractionFollowUpContent(
|
|
211
|
+
dispatch,
|
|
212
|
+
createTranslator({ 'channel_discord.interactions.commandReceived': 'Zapisano /{command}.' }),
|
|
213
|
+
)
|
|
214
|
+
expect(content).toBe('Zapisano /mercato.')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('answers in English rather than echoing a key when no dictionary is loaded', () => {
|
|
218
|
+
const dispatch = readDispatchableInteraction(slashCommand(), { now }) as DispatchableInteraction
|
|
219
|
+
const content = buildInteractionFollowUpContent(dispatch, createTranslator({}))
|
|
220
|
+
expect(content).toContain('/mercato')
|
|
221
|
+
expect(content).not.toContain('channel_discord.interactions')
|
|
222
|
+
})
|
|
223
|
+
})
|