@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,113 @@
|
|
|
1
|
+
import { createTranslator } from "@open-mercato/shared/lib/i18n/translate";
|
|
2
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
3
|
+
import {
|
|
4
|
+
COMMUNICATION_CHANNELS_QUEUES,
|
|
5
|
+
getCommunicationChannelsQueue
|
|
6
|
+
} from "@open-mercato/core/modules/communication_channels/lib/queue";
|
|
7
|
+
import { parseDiscordCredentialsOrThrow } from "../lib/credentials.js";
|
|
8
|
+
import { getDiscordRestClient } from "../lib/discord-rest.js";
|
|
9
|
+
import {
|
|
10
|
+
buildInteractionFollowUpContent,
|
|
11
|
+
buildInteractionInboundJob,
|
|
12
|
+
sendInteractionFollowUp
|
|
13
|
+
} from "../lib/interactions-dispatch.js";
|
|
14
|
+
import {
|
|
15
|
+
CHANNEL_DISCORD_INTERACTIONS_QUEUE
|
|
16
|
+
} from "../lib/interactions-queue.js";
|
|
17
|
+
const logger = createLogger("channel_discord").child({ component: "interactions-worker" });
|
|
18
|
+
const metadata = {
|
|
19
|
+
queue: CHANNEL_DISCORD_INTERACTIONS_QUEUE,
|
|
20
|
+
id: "channel_discord:interactions",
|
|
21
|
+
concurrency: 5
|
|
22
|
+
};
|
|
23
|
+
async function resolveFollowUpContent(interaction) {
|
|
24
|
+
try {
|
|
25
|
+
const { resolveTranslations } = await import("@open-mercato/shared/lib/i18n/server");
|
|
26
|
+
const { t } = await resolveTranslations();
|
|
27
|
+
return buildInteractionFollowUpContent(interaction, t);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
logger.debug("falling back to the untranslated interaction acknowledgement", { err });
|
|
30
|
+
return buildInteractionFollowUpContent(interaction, createTranslator({}));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function handle(job, ctx) {
|
|
34
|
+
const payload = job.payload;
|
|
35
|
+
if (!payload?.interaction) {
|
|
36
|
+
logger.warn("interaction dispatch job carried no interaction \u2014 dropping");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const { interaction } = payload;
|
|
40
|
+
const inboundJob = buildInteractionInboundJob({
|
|
41
|
+
dispatch: interaction,
|
|
42
|
+
channel: {
|
|
43
|
+
channelId: payload.channelId,
|
|
44
|
+
channelType: payload.channelType,
|
|
45
|
+
tenantId: payload.tenantId,
|
|
46
|
+
organizationId: payload.organizationId
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
if (inboundJob) {
|
|
50
|
+
await getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.inbound).enqueue(
|
|
51
|
+
inboundJob
|
|
52
|
+
);
|
|
53
|
+
logger.debug("discord interaction enqueued to the hub inbound queue", {
|
|
54
|
+
channelId: payload.channelId,
|
|
55
|
+
interactionId: interaction.id,
|
|
56
|
+
queue: COMMUNICATION_CHANNELS_QUEUES.inbound
|
|
57
|
+
});
|
|
58
|
+
} else {
|
|
59
|
+
logger.debug("discord interaction dropped \u2014 invoked by a bot", {
|
|
60
|
+
channelId: payload.channelId,
|
|
61
|
+
interactionId: interaction.id
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
let credentialsService = null;
|
|
65
|
+
try {
|
|
66
|
+
credentialsService = ctx.resolve("integrationCredentialsService");
|
|
67
|
+
} catch {
|
|
68
|
+
credentialsService = null;
|
|
69
|
+
}
|
|
70
|
+
if (!credentialsService) {
|
|
71
|
+
logger.warn("integrationCredentialsService unavailable \u2014 cannot acknowledge the interaction", {
|
|
72
|
+
channelId: payload.channelId
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const credentials = await credentialsService.resolve("channel_discord", {
|
|
77
|
+
tenantId: payload.credentialScope.tenantId,
|
|
78
|
+
organizationId: payload.credentialScope.organizationId,
|
|
79
|
+
userId: payload.credentialScope.userId
|
|
80
|
+
});
|
|
81
|
+
if (!credentials) {
|
|
82
|
+
logger.warn("no Discord credentials for the interaction channel \u2014 cannot acknowledge", {
|
|
83
|
+
channelId: payload.channelId
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const parsed = parseDiscordCredentialsOrThrow(credentials);
|
|
88
|
+
if (parsed.applicationId !== interaction.applicationId) {
|
|
89
|
+
logger.warn("interaction application id does not match the stored credentials", {
|
|
90
|
+
channelId: payload.channelId,
|
|
91
|
+
interactionId: interaction.id
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const delivery = await sendInteractionFollowUp(getDiscordRestClient(), {
|
|
95
|
+
// Addressed with the application the interaction itself carries: the whole
|
|
96
|
+
// body is Ed25519-signed, so this value is as verified as the channel match.
|
|
97
|
+
applicationId: interaction.applicationId,
|
|
98
|
+
interactionToken: interaction.token,
|
|
99
|
+
content: await resolveFollowUpContent(interaction),
|
|
100
|
+
ephemeral: true
|
|
101
|
+
});
|
|
102
|
+
logger.info("discord interaction acknowledged", {
|
|
103
|
+
channelId: payload.channelId,
|
|
104
|
+
interactionId: interaction.id,
|
|
105
|
+
interactionType: interaction.type,
|
|
106
|
+
delivery
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export {
|
|
110
|
+
handle as default,
|
|
111
|
+
metadata
|
|
112
|
+
};
|
|
113
|
+
//# sourceMappingURL=discord-interactions.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_discord/workers/discord-interactions.ts"],
|
|
4
|
+
"sourcesContent": ["import type { QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport { createTranslator } from '@open-mercato/shared/lib/i18n/translate'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n COMMUNICATION_CHANNELS_QUEUES,\n getCommunicationChannelsQueue,\n} from '@open-mercato/core/modules/communication_channels/lib/queue'\nimport { parseDiscordCredentialsOrThrow } from '../lib/credentials'\nimport { getDiscordRestClient } from '../lib/discord-rest'\nimport {\n buildInteractionFollowUpContent,\n buildInteractionInboundJob,\n sendInteractionFollowUp,\n type DispatchableInteraction,\n} from '../lib/interactions-dispatch'\nimport {\n CHANNEL_DISCORD_INTERACTIONS_QUEUE,\n type InteractionDispatchJobPayload,\n} from '../lib/interactions-queue'\n\nconst logger = createLogger('channel_discord').child({ component: 'interactions-worker' })\n\n/**\n * Discord interaction dispatch worker.\n *\n * WHY A WORKER: Discord gives the Interactions endpoint three seconds to answer,\n * and closes the interaction if it misses. So the route verifies the signature,\n * returns a deferred acknowledgement, and hands the rest here \u2014 where the two\n * slow halves can take as long as they need: writing the interaction into the\n * hub, and replacing the \"thinking\u2026\" placeholder with a real message.\n *\n * The queue is the ORDINARY module queue, consumed by the app's standard worker\n * runner \u2014 unlike `channel_discord_gateway`, nothing has to be started by hand.\n *\n * IDEMPOTENCE: the hub dedups on `(channel_id, external_message_id)` and the\n * external id is the interaction snowflake, so a retry after a failed follow-up\n * re-enqueues the same inbound job harmlessly rather than duplicating the record.\n */\nexport const metadata: WorkerMeta = {\n queue: CHANNEL_DISCORD_INTERACTIONS_QUEUE,\n id: 'channel_discord:interactions',\n concurrency: 5,\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n}\n\ntype HandlerContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\n/**\n * Translate the acknowledgement, falling back to each key's English default when\n * no dictionary is available \u2014 a worker process that never loaded the app's\n * modules must still send the user a readable answer rather than a translation\n * key. `resolveTranslations` is imported dynamically because it pulls in\n * `server-only`, which has no business in this module's static graph.\n */\nasync function resolveFollowUpContent(interaction: DispatchableInteraction): Promise<string> {\n try {\n const { resolveTranslations } = await import('@open-mercato/shared/lib/i18n/server')\n const { t } = await resolveTranslations()\n return buildInteractionFollowUpContent(interaction, t)\n } catch (err) {\n logger.debug('falling back to the untranslated interaction acknowledgement', { err })\n return buildInteractionFollowUpContent(interaction, createTranslator({}))\n }\n}\n\nexport default async function handle(\n job: QueuedJob<InteractionDispatchJobPayload>,\n ctx: HandlerContext,\n): Promise<void> {\n const payload = job.payload\n if (!payload?.interaction) {\n logger.warn('interaction dispatch job carried no interaction \u2014 dropping')\n return\n }\n\n const { interaction } = payload\n\n const inboundJob = buildInteractionInboundJob({\n dispatch: interaction,\n channel: {\n channelId: payload.channelId,\n channelType: payload.channelType,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n },\n })\n if (inboundJob) {\n await getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.inbound).enqueue(\n inboundJob as unknown as Record<string, unknown>,\n )\n logger.debug('discord interaction enqueued to the hub inbound queue', {\n channelId: payload.channelId,\n interactionId: interaction.id,\n queue: COMMUNICATION_CHANNELS_QUEUES.inbound,\n })\n } else {\n logger.debug('discord interaction dropped \u2014 invoked by a bot', {\n channelId: payload.channelId,\n interactionId: interaction.id,\n })\n }\n\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.resolve<CredentialsServiceLike>('integrationCredentialsService')\n } catch {\n credentialsService = null\n }\n if (!credentialsService) {\n logger.warn('integrationCredentialsService unavailable \u2014 cannot acknowledge the interaction', {\n channelId: payload.channelId,\n })\n return\n }\n\n const credentials = await credentialsService.resolve('channel_discord', {\n tenantId: payload.credentialScope.tenantId,\n organizationId: payload.credentialScope.organizationId,\n userId: payload.credentialScope.userId,\n })\n if (!credentials) {\n logger.warn('no Discord credentials for the interaction channel \u2014 cannot acknowledge', {\n channelId: payload.channelId,\n })\n return\n }\n const parsed = parseDiscordCredentialsOrThrow(credentials)\n if (parsed.applicationId !== interaction.applicationId) {\n // The interaction verified against this channel's public key, so the two\n // ought to agree. When they do not the credential bag has drifted from the\n // application that actually signed, and following up under the stored id\n // would 401 \u2014 say so once rather than retrying blindly.\n logger.warn('interaction application id does not match the stored credentials', {\n channelId: payload.channelId,\n interactionId: interaction.id,\n })\n }\n\n const delivery = await sendInteractionFollowUp(getDiscordRestClient(), {\n // Addressed with the application the interaction itself carries: the whole\n // body is Ed25519-signed, so this value is as verified as the channel match.\n applicationId: interaction.applicationId,\n interactionToken: interaction.token,\n content: await resolveFollowUpContent(interaction),\n ephemeral: true,\n })\n\n logger.info('discord interaction acknowledged', {\n channelId: payload.channelId,\n interactionId: interaction.id,\n interactionType: interaction.type,\n delivery,\n })\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,wBAAwB;AACjC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,sCAAsC;AAC/C,SAAS,4BAA4B;AACrC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP;AAAA,EACE;AAAA,OAEK;AAEP,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,sBAAsB,CAAC;AAkBlF,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAoBA,eAAe,uBAAuB,aAAuD;AAC3F,MAAI;AACF,UAAM,EAAE,oBAAoB,IAAI,MAAM,OAAO,sCAAsC;AACnF,UAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,WAAO,gCAAgC,aAAa,CAAC;AAAA,EACvD,SAAS,KAAK;AACZ,WAAO,MAAM,gEAAgE,EAAE,IAAI,CAAC;AACpF,WAAO,gCAAgC,aAAa,iBAAiB,CAAC,CAAC,CAAC;AAAA,EAC1E;AACF;AAEA,eAAO,OACL,KACA,KACe;AACf,QAAM,UAAU,IAAI;AACpB,MAAI,CAAC,SAAS,aAAa;AACzB,WAAO,KAAK,iEAA4D;AACxE;AAAA,EACF;AAEA,QAAM,EAAE,YAAY,IAAI;AAExB,QAAM,aAAa,2BAA2B;AAAA,IAC5C,UAAU;AAAA,IACV,SAAS;AAAA,MACP,WAAW,QAAQ;AAAA,MACnB,aAAa,QAAQ;AAAA,MACrB,UAAU,QAAQ;AAAA,MAClB,gBAAgB,QAAQ;AAAA,IAC1B;AAAA,EACF,CAAC;AACD,MAAI,YAAY;AACd,UAAM,8BAA8B,8BAA8B,OAAO,EAAE;AAAA,MACzE;AAAA,IACF;AACA,WAAO,MAAM,yDAAyD;AAAA,MACpE,WAAW,QAAQ;AAAA,MACnB,eAAe,YAAY;AAAA,MAC3B,OAAO,8BAA8B;AAAA,IACvC,CAAC;AAAA,EACH,OAAO;AACL,WAAO,MAAM,uDAAkD;AAAA,MAC7D,WAAW,QAAQ;AAAA,MACnB,eAAe,YAAY;AAAA,IAC7B,CAAC;AAAA,EACH;AAEA,MAAI,qBAAoD;AACxD,MAAI;AACF,yBAAqB,IAAI,QAAgC,+BAA+B;AAAA,EAC1F,QAAQ;AACN,yBAAqB;AAAA,EACvB;AACA,MAAI,CAAC,oBAAoB;AACvB,WAAO,KAAK,uFAAkF;AAAA,MAC5F,WAAW,QAAQ;AAAA,IACrB,CAAC;AACD;AAAA,EACF;AAEA,QAAM,cAAc,MAAM,mBAAmB,QAAQ,mBAAmB;AAAA,IACtE,UAAU,QAAQ,gBAAgB;AAAA,IAClC,gBAAgB,QAAQ,gBAAgB;AAAA,IACxC,QAAQ,QAAQ,gBAAgB;AAAA,EAClC,CAAC;AACD,MAAI,CAAC,aAAa;AAChB,WAAO,KAAK,gFAA2E;AAAA,MACrF,WAAW,QAAQ;AAAA,IACrB,CAAC;AACD;AAAA,EACF;AACA,QAAM,SAAS,+BAA+B,WAAW;AACzD,MAAI,OAAO,kBAAkB,YAAY,eAAe;AAKtD,WAAO,KAAK,oEAAoE;AAAA,MAC9E,WAAW,QAAQ;AAAA,MACnB,eAAe,YAAY;AAAA,IAC7B,CAAC;AAAA,EACH;AAEA,QAAM,WAAW,MAAM,wBAAwB,qBAAqB,GAAG;AAAA;AAAA;AAAA,IAGrE,eAAe,YAAY;AAAA,IAC3B,kBAAkB,YAAY;AAAA,IAC9B,SAAS,MAAM,uBAAuB,WAAW;AAAA,IACjD,WAAW;AAAA,EACb,CAAC;AAED,SAAO,KAAK,oCAAoC;AAAA,IAC9C,WAAW,QAAQ;AAAA,IACnB,eAAe,YAAY;AAAA,IAC3B,iBAAiB,YAAY;AAAA,IAC7B;AAAA,EACF,CAAC;AACH;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/jest.config.cjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** @type {import('jest').Config} */
|
|
2
|
+
const base = require('../../jest.config.base.cjs')
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
...base,
|
|
6
|
+
testEnvironment: 'node',
|
|
7
|
+
watchman: false,
|
|
8
|
+
rootDir: '.',
|
|
9
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
|
10
|
+
moduleNameMapper: {
|
|
11
|
+
'^@open-mercato/channel-discord/(.*)$': '<rootDir>/src/$1',
|
|
12
|
+
'^@open-mercato/core/(.*)$': '<rootDir>/../core/src/$1',
|
|
13
|
+
// Test-only: lets the policy/runtime integration spec drive the REAL
|
|
14
|
+
// `agent-policy` / `agent-runtime` instead of a stub. The runtime dependency
|
|
15
|
+
// stays soft — production code reaches the peer through a dynamic import.
|
|
16
|
+
'^@open-mercato/ai-assistant/(.*)$': '<rootDir>/../ai-assistant/src/$1',
|
|
17
|
+
'^@open-mercato/shared/(.*)$': '<rootDir>/../shared/src/$1',
|
|
18
|
+
'^@open-mercato/queue/(.*)$': '<rootDir>/../queue/src/$1',
|
|
19
|
+
'^@open-mercato/ui/(.*)$': '<rootDir>/../ui/src/$1',
|
|
20
|
+
},
|
|
21
|
+
transform: {
|
|
22
|
+
'^.+\\.(t|j)sx?$': [
|
|
23
|
+
'<rootDir>/../../scripts/jest-mikroorm-transformer.cjs',
|
|
24
|
+
{
|
|
25
|
+
tsconfig: {
|
|
26
|
+
jsx: 'react-jsx',
|
|
27
|
+
rootDir: '.',
|
|
28
|
+
ignoreDeprecations: '6.0',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
transformIgnorePatterns: [
|
|
34
|
+
'node_modules/(?!(@mikro-orm|kysely|ai|@ai-sdk|ai-sdk-ollama|@workflow|@standard-schema)/)',
|
|
35
|
+
],
|
|
36
|
+
testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
|
|
37
|
+
passWithNoTests: true,
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-mercato/channel-discord",
|
|
3
|
+
"version": "0.6.8-develop.7090.1.4dbca7b350",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "node build.mjs",
|
|
9
|
+
"watch": "node watch.mjs",
|
|
10
|
+
"test": "jest --config jest.config.cjs",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./dist/index.js",
|
|
15
|
+
"./*.ts": {
|
|
16
|
+
"types": "./src/*.ts",
|
|
17
|
+
"default": "./dist/*.js"
|
|
18
|
+
},
|
|
19
|
+
"./*.tsx": {
|
|
20
|
+
"types": "./src/*.tsx",
|
|
21
|
+
"default": "./dist/*.js"
|
|
22
|
+
},
|
|
23
|
+
"./*.json": "./src/*.json",
|
|
24
|
+
"./*": {
|
|
25
|
+
"types": [
|
|
26
|
+
"./src/*.ts",
|
|
27
|
+
"./src/*.tsx"
|
|
28
|
+
],
|
|
29
|
+
"default": "./dist/*.js"
|
|
30
|
+
},
|
|
31
|
+
"./*/*.json": "./src/*/*.json",
|
|
32
|
+
"./*/*": {
|
|
33
|
+
"types": [
|
|
34
|
+
"./src/*/*.ts",
|
|
35
|
+
"./src/*/*.tsx"
|
|
36
|
+
],
|
|
37
|
+
"default": "./dist/*/*.js"
|
|
38
|
+
},
|
|
39
|
+
"./*/*/*.json": "./src/*/*/*.json",
|
|
40
|
+
"./*/*/*": {
|
|
41
|
+
"types": [
|
|
42
|
+
"./src/*/*/*.ts",
|
|
43
|
+
"./src/*/*/*.tsx"
|
|
44
|
+
],
|
|
45
|
+
"default": "./dist/*/*/*.js"
|
|
46
|
+
},
|
|
47
|
+
"./*/*/*/*.json": "./src/*/*/*/*.json",
|
|
48
|
+
"./*/*/*/*": {
|
|
49
|
+
"types": [
|
|
50
|
+
"./src/*/*/*/*.ts",
|
|
51
|
+
"./src/*/*/*/*.tsx"
|
|
52
|
+
],
|
|
53
|
+
"default": "./dist/*/*/*/*.js"
|
|
54
|
+
},
|
|
55
|
+
"./*/*/*/*/*.json": "./src/*/*/*/*/*.json",
|
|
56
|
+
"./*/*/*/*/*": {
|
|
57
|
+
"types": [
|
|
58
|
+
"./src/*/*/*/*/*.ts",
|
|
59
|
+
"./src/*/*/*/*/*.tsx"
|
|
60
|
+
],
|
|
61
|
+
"default": "./dist/*/*/*/*/*.js"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"@open-mercato/core": "0.6.8-develop.7090.1.4dbca7b350",
|
|
66
|
+
"@open-mercato/queue": "0.6.8-develop.7090.1.4dbca7b350",
|
|
67
|
+
"@open-mercato/ui": "0.6.8-develop.7090.1.4dbca7b350"
|
|
68
|
+
},
|
|
69
|
+
"peerDependencies": {
|
|
70
|
+
"@mikro-orm/postgresql": "^7.0.14",
|
|
71
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7090.1.4dbca7b350",
|
|
72
|
+
"@open-mercato/shared": "0.6.8-develop.7090.1.4dbca7b350",
|
|
73
|
+
"react": "^19.0.0",
|
|
74
|
+
"react-dom": "^19.0.0"
|
|
75
|
+
},
|
|
76
|
+
"peerDependenciesMeta": {
|
|
77
|
+
"@open-mercato/ai-assistant": {
|
|
78
|
+
"optional": true
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7090.1.4dbca7b350",
|
|
83
|
+
"@open-mercato/shared": "0.6.8-develop.7090.1.4dbca7b350",
|
|
84
|
+
"@types/jest": "^30.0.0",
|
|
85
|
+
"@types/react": "^19.2.17",
|
|
86
|
+
"@types/react-dom": "^19.2.3",
|
|
87
|
+
"esbuild": "^0.28.1",
|
|
88
|
+
"glob": "^13.0.6",
|
|
89
|
+
"jest": "^30.4.2",
|
|
90
|
+
"react": "19.2.8",
|
|
91
|
+
"react-dom": "19.2.8",
|
|
92
|
+
"ts-jest": "^29.4.12",
|
|
93
|
+
"typescript": "7.0.2"
|
|
94
|
+
},
|
|
95
|
+
"publishConfig": {
|
|
96
|
+
"access": "public"
|
|
97
|
+
},
|
|
98
|
+
"repository": {
|
|
99
|
+
"type": "git",
|
|
100
|
+
"url": "https://github.com/open-mercato/open-mercato",
|
|
101
|
+
"directory": "packages/channel-discord"
|
|
102
|
+
},
|
|
103
|
+
"stableVersion": "0.6.7"
|
|
104
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { metadata } from './modules/channel_discord/index'
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TC-CHANNEL-DISCORD-001 — connect a Discord channel via credentials.
|
|
7
|
+
* Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
|
|
8
|
+
*
|
|
9
|
+
* Asserts the two halves of the connect contract through the hub's real route:
|
|
10
|
+
*
|
|
11
|
+
* 1. The `discord` adapter IS registered with the hub — an unknown provider key
|
|
12
|
+
* resolves to no adapter and returns 404, `discord` does not.
|
|
13
|
+
* 2. `validateCredentials` is fail-closed and offline — a malformed credential
|
|
14
|
+
* blob is rejected with per-field errors (422) by the zod schema BEFORE any
|
|
15
|
+
* Discord REST call, so this test needs no bot token and no network.
|
|
16
|
+
*
|
|
17
|
+
* The happy path (a real token accepted by `GET /users/@me`) needs live Discord
|
|
18
|
+
* and stays unit-tested with a stubbed REST client in
|
|
19
|
+
* `lib/__tests__/adapter.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
test.describe('TC-CHANNEL-DISCORD-001: connect channel via credentials', () => {
|
|
22
|
+
test('requires authentication', async ({ request }) => {
|
|
23
|
+
const response = await apiRequest(
|
|
24
|
+
request,
|
|
25
|
+
'POST',
|
|
26
|
+
'/api/communication_channels/channels/connect/credentials',
|
|
27
|
+
// Intentionally empty token — this assertion is the 401 unauth path.
|
|
28
|
+
{ token: '', data: { providerKey: 'discord', displayName: 'unauth', credentials: {} } },
|
|
29
|
+
)
|
|
30
|
+
expect(response.status(), 'connect must reject an unauthenticated caller').toBe(401)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('the discord adapter is registered and rejects malformed credentials with field errors', async ({
|
|
34
|
+
request,
|
|
35
|
+
}) => {
|
|
36
|
+
const token = await getAuthToken(request, 'admin')
|
|
37
|
+
const stamp = Date.now()
|
|
38
|
+
|
|
39
|
+
const unknownProvider = await apiRequest(
|
|
40
|
+
request,
|
|
41
|
+
'POST',
|
|
42
|
+
'/api/communication_channels/channels/connect/credentials',
|
|
43
|
+
{
|
|
44
|
+
token,
|
|
45
|
+
data: {
|
|
46
|
+
providerKey: `not-a-provider-${stamp}`,
|
|
47
|
+
displayName: `TC-CHANNEL-DISCORD-001 unknown ${stamp}`,
|
|
48
|
+
credentials: {},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
expect(
|
|
53
|
+
unknownProvider.status(),
|
|
54
|
+
'an unregistered provider key must resolve to no adapter (404)',
|
|
55
|
+
).toBe(404)
|
|
56
|
+
|
|
57
|
+
const discord = await apiRequest(
|
|
58
|
+
request,
|
|
59
|
+
'POST',
|
|
60
|
+
'/api/communication_channels/channels/connect/credentials',
|
|
61
|
+
{
|
|
62
|
+
token,
|
|
63
|
+
data: {
|
|
64
|
+
providerKey: 'discord',
|
|
65
|
+
displayName: `TC-CHANNEL-DISCORD-001 ${stamp}`,
|
|
66
|
+
// Missing botToken/applicationId, and a publicKey that is neither hex
|
|
67
|
+
// nor 32 bytes — all three must come back as field errors.
|
|
68
|
+
credentials: { publicKey: 'not-a-hex-key' },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
expect(
|
|
73
|
+
discord.status(),
|
|
74
|
+
'discord IS registered, so the failure must be credential validation (422), not 404',
|
|
75
|
+
).toBe(422)
|
|
76
|
+
|
|
77
|
+
const body = await readJsonSafe<{ error?: string; fieldErrors?: Record<string, string> }>(discord)
|
|
78
|
+
expect(body?.error, 'the 422 must report credential validation').toBe('Credential validation failed')
|
|
79
|
+
const fieldErrors = body?.fieldErrors ?? {}
|
|
80
|
+
expect(
|
|
81
|
+
Object.keys(fieldErrors),
|
|
82
|
+
'every missing/invalid credential field must be reported back to the operator',
|
|
83
|
+
).toEqual(expect.arrayContaining(['botToken', 'applicationId', 'publicKey']))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('a rejected connect attempt creates no channel', async ({ request }) => {
|
|
87
|
+
const token = await getAuthToken(request, 'admin')
|
|
88
|
+
const stamp = Date.now()
|
|
89
|
+
const displayName = `TC-CHANNEL-DISCORD-001 orphan ${stamp}`
|
|
90
|
+
|
|
91
|
+
const rejected = await apiRequest(
|
|
92
|
+
request,
|
|
93
|
+
'POST',
|
|
94
|
+
'/api/communication_channels/channels/connect/credentials',
|
|
95
|
+
{ token, data: { providerKey: 'discord', displayName, credentials: {} } },
|
|
96
|
+
)
|
|
97
|
+
expect(rejected.status(), 'malformed credentials must be rejected').toBe(422)
|
|
98
|
+
|
|
99
|
+
const listed = await apiRequest(request, 'GET', '/api/communication_channels/me/channels', {
|
|
100
|
+
token,
|
|
101
|
+
})
|
|
102
|
+
expect(listed.status(), 'GET /me/channels should return 200').toBe(200)
|
|
103
|
+
const body = await readJsonSafe<{ items?: Array<{ displayName?: string }> }>(listed)
|
|
104
|
+
const names = (body?.items ?? []).map((item) => item.displayName)
|
|
105
|
+
expect(names, 'a failed validation must not leave a half-connected channel behind').not.toContain(
|
|
106
|
+
displayName,
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TC-CHANNEL-DISCORD-002 — outbound test-send routing for a Discord channel.
|
|
6
|
+
* Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
|
|
7
|
+
*
|
|
8
|
+
* The hub owns the outbound entry point (`POST /channels/[id]/test-send`); the
|
|
9
|
+
* provider only supplies `convertOutbound` + `sendMessage`. This spec pins the
|
|
10
|
+
* route's tenant-safety contract, which is what a provider PR can break: an
|
|
11
|
+
* unauthenticated caller, a malformed id, and an id the caller does not own are
|
|
12
|
+
* all refused BEFORE any adapter is resolved — so no Discord REST call can be
|
|
13
|
+
* driven by an unauthorized request.
|
|
14
|
+
*
|
|
15
|
+
* The actual Discord payload conversion and REST post are unit-tested against a
|
|
16
|
+
* stubbed client in `lib/__tests__/convert-outbound.test.ts` and
|
|
17
|
+
* `lib/__tests__/adapter.test.ts`; they need a live bot token and are therefore
|
|
18
|
+
* out of scope for CI.
|
|
19
|
+
*/
|
|
20
|
+
const UNKNOWN_CHANNEL_ID = '00000000-0000-4000-8000-0000000d1c02'
|
|
21
|
+
|
|
22
|
+
test.describe('TC-CHANNEL-DISCORD-002: outbound test-send guards', () => {
|
|
23
|
+
test('requires authentication', async ({ request }) => {
|
|
24
|
+
const response = await apiRequest(
|
|
25
|
+
request,
|
|
26
|
+
'POST',
|
|
27
|
+
`/api/communication_channels/channels/${UNKNOWN_CHANNEL_ID}/test-send`,
|
|
28
|
+
// Intentionally empty token — this assertion is the 401 unauth path.
|
|
29
|
+
{ token: '', data: { to: 'integration@example.com' } },
|
|
30
|
+
)
|
|
31
|
+
expect(response.status(), 'test-send must reject an unauthenticated caller').toBe(401)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('rejects a malformed channel id before touching any adapter', async ({ request }) => {
|
|
35
|
+
const token = await getAuthToken(request, 'admin')
|
|
36
|
+
const response = await apiRequest(
|
|
37
|
+
request,
|
|
38
|
+
'POST',
|
|
39
|
+
'/api/communication_channels/channels/not-a-uuid/test-send',
|
|
40
|
+
{ token, data: { to: 'integration@example.com' } },
|
|
41
|
+
)
|
|
42
|
+
expect(response.status(), 'a non-uuid channel id must be a 400, not a 500').toBe(400)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('rejects a channel the caller does not own', async ({ request }) => {
|
|
46
|
+
const token = await getAuthToken(request, 'admin')
|
|
47
|
+
const response = await apiRequest(
|
|
48
|
+
request,
|
|
49
|
+
'POST',
|
|
50
|
+
`/api/communication_channels/channels/${UNKNOWN_CHANNEL_ID}/test-send`,
|
|
51
|
+
{ token, data: { to: 'integration@example.com' } },
|
|
52
|
+
)
|
|
53
|
+
expect(
|
|
54
|
+
response.status(),
|
|
55
|
+
'an unknown/foreign channel id must be 404 — never a send attempt',
|
|
56
|
+
).toBe(404)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
+
import {
|
|
5
|
+
deleteChannelIfExists,
|
|
6
|
+
ingestInboundChatMessage,
|
|
7
|
+
isChannelSeedingAvailable,
|
|
8
|
+
seedConnectedChannel,
|
|
9
|
+
} from '@open-mercato/core/helpers/integration/communicationChannelsFixtures'
|
|
10
|
+
import { normalizeInboundDiscordMessage } from '../lib/normalize-inbound'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* TC-CHANNEL-DISCORD-003 — a message shaped the way the Discord gateway
|
|
14
|
+
* actually produces it is ingested end to end.
|
|
15
|
+
* Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ HISTORY, because this spec earned the scepticism (QA of #4391, @Kapsik89
|
|
18
|
+
* 2026-08-04 → #4975).
|
|
19
|
+
*
|
|
20
|
+
* The first version of this spec claimed to prove that "an inbound Discord
|
|
21
|
+
* message lands in the hub" and stayed green through three live defects. It fed
|
|
22
|
+
* the hub an invented `…@test-seed.local` address for both the channel
|
|
23
|
+
* identifier and the recipient, because the hub's `composeMessageSchema`
|
|
24
|
+
* demanded `externalEmail` for every public message. A real Discord channel
|
|
25
|
+
* carries `external_identifier = NULL` and a real Discord sender has no address
|
|
26
|
+
* at all, so `normalizeInboundDiscordMessage` never ran on that path — the spec
|
|
27
|
+
* asserted a landing zone the real provider could never reach.
|
|
28
|
+
*
|
|
29
|
+
* Both halves of that are fixed now:
|
|
30
|
+
* - #4975 (merged as #5252) made the `externalEmail` requirement conditional
|
|
31
|
+
* on the originating channel being email-typed, fail-closed.
|
|
32
|
+
* - the test-seed `ingest-inbound` action drives the real
|
|
33
|
+
* `ingest_inbound_message` command, which composes through
|
|
34
|
+
* `messages.messages.compose` — no SQL shortcut, no seeded rows.
|
|
35
|
+
*
|
|
36
|
+
* So this spec now does what its name always claimed:
|
|
37
|
+
* 1. it starts from a verbatim Discord `MESSAGE_CREATE` frame,
|
|
38
|
+
* 2. runs it through the **real** `normalizeInboundDiscordMessage` — the same
|
|
39
|
+
* function the gateway worker calls, not a re-implementation,
|
|
40
|
+
* 3. hands the normalized result to the hub with **no address anywhere**, and
|
|
41
|
+
* 4. asserts a platform message exists at the other end.
|
|
42
|
+
*
|
|
43
|
+
* Ceiling, stated rather than implied: the transport is the chat-flavoured
|
|
44
|
+
* test-seed adapter, because connecting a real Discord channel requires a live
|
|
45
|
+
* bot token and a live credential probe, neither of which exists in CI. What is
|
|
46
|
+
* NOT stubbed is the part that was broken — the Discord frame, the Discord
|
|
47
|
+
* normalizer, and the hub's compose validation. The socket state machine
|
|
48
|
+
* (identify/resume/backoff, bot-self filtering, replay dedup) is a pure state
|
|
49
|
+
* machine covered by `lib/__tests__/discord-gateway-client.test.ts` and
|
|
50
|
+
* `lib/__tests__/gateway-bridge.test.ts`.
|
|
51
|
+
*
|
|
52
|
+
* Driven via the env-gated test-seed fixture (`OM_ENABLE_TEST_CHANNEL_SEEDING`);
|
|
53
|
+
* skips when the gate is off.
|
|
54
|
+
*/
|
|
55
|
+
type HealthSnapshot = {
|
|
56
|
+
channelId?: string
|
|
57
|
+
counts?: Record<string, number>
|
|
58
|
+
totalsLast24h?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readHealth(
|
|
62
|
+
request: Parameters<typeof apiRequest>[0],
|
|
63
|
+
token: string,
|
|
64
|
+
channelId: string,
|
|
65
|
+
): Promise<HealthSnapshot> {
|
|
66
|
+
const response = await apiRequest(
|
|
67
|
+
request,
|
|
68
|
+
'GET',
|
|
69
|
+
`/api/communication_channels/channels/${channelId}/health`,
|
|
70
|
+
{ token },
|
|
71
|
+
)
|
|
72
|
+
expect(response.status(), 'GET /channels/[id]/health should return 200').toBe(200)
|
|
73
|
+
return (await readJsonSafe<HealthSnapshot>(response)) ?? {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A `MESSAGE_CREATE` frame in the exact shape Discord delivers it. The values
|
|
78
|
+
* are the ones QA captured from a real guild while filing #4975 — in particular
|
|
79
|
+
* the author id is a genuine snowflake, which is the whole point: there is no
|
|
80
|
+
* address on it, and none is invented anywhere below.
|
|
81
|
+
*/
|
|
82
|
+
function discordMessageCreateFrame(stamp: number) {
|
|
83
|
+
return {
|
|
84
|
+
id: `1534333813851816${String(stamp).slice(-3)}`,
|
|
85
|
+
channel_id: '1534331920463433771',
|
|
86
|
+
guild_id: '1534331919796273152',
|
|
87
|
+
content: 'Kolejna wiadomość testowa!@',
|
|
88
|
+
timestamp: new Date().toISOString(),
|
|
89
|
+
author: {
|
|
90
|
+
id: '1499156851487539260',
|
|
91
|
+
username: 'lorakaspak',
|
|
92
|
+
global_name: 'Karol Kapsa',
|
|
93
|
+
bot: false,
|
|
94
|
+
},
|
|
95
|
+
attachments: [],
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test.describe('TC-CHANNEL-DISCORD-003: inbound discord message ingest', () => {
|
|
100
|
+
test('a message shaped the way the Discord gateway actually produces it is ingested', async ({
|
|
101
|
+
request,
|
|
102
|
+
}) => {
|
|
103
|
+
test.slow()
|
|
104
|
+
let token: string | null = null
|
|
105
|
+
let channelId: string | null = null
|
|
106
|
+
try {
|
|
107
|
+
token = await getAuthToken(request, 'admin')
|
|
108
|
+
const seedingAvailable = await isChannelSeedingAvailable(request, token)
|
|
109
|
+
test.skip(
|
|
110
|
+
!seedingAvailable,
|
|
111
|
+
'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot ingest.',
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const stamp = Date.now()
|
|
115
|
+
// `providerFlavor: 'chat'` connects the non-email stub, so the channel row
|
|
116
|
+
// carries a non-email `channelType` and a NULL `externalIdentifier` — the
|
|
117
|
+
// shape a real Discord channel has. No `externalIdentifier` is passed.
|
|
118
|
+
channelId = await seedConnectedChannel(request, token, {
|
|
119
|
+
displayName: `TC-CHANNEL-DISCORD-003 ${stamp}`,
|
|
120
|
+
providerFlavor: 'chat',
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const before = await readHealth(request, token, channelId)
|
|
124
|
+
const totalBefore = before.totalsLast24h ?? 0
|
|
125
|
+
|
|
126
|
+
// The real normalizer, on a real frame. If Discord's payload shape and the
|
|
127
|
+
// hub's expectations ever drift apart, this line is where it shows.
|
|
128
|
+
const frame = discordMessageCreateFrame(stamp)
|
|
129
|
+
const normalized = normalizeInboundDiscordMessage(frame as never)
|
|
130
|
+
|
|
131
|
+
expect(
|
|
132
|
+
normalized.senderIdentifier,
|
|
133
|
+
'the sender must stay the Discord snowflake — synthesising an address here is what #4975 rejects',
|
|
134
|
+
).toBe('1499156851487539260')
|
|
135
|
+
expect(normalized.externalConversationId).toBe('discord-channel:1534331920463433771')
|
|
136
|
+
expect(
|
|
137
|
+
(normalized as { externalEmail?: unknown }).externalEmail,
|
|
138
|
+
'the normalizer must not invent an address',
|
|
139
|
+
).toBeUndefined()
|
|
140
|
+
|
|
141
|
+
const ingested = await ingestInboundChatMessage(request, token, {
|
|
142
|
+
channelId,
|
|
143
|
+
senderIdentifier: normalized.senderIdentifier,
|
|
144
|
+
senderDisplayName: normalized.senderDisplayName,
|
|
145
|
+
body: normalized.body,
|
|
146
|
+
externalMessageId: normalized.externalMessageId,
|
|
147
|
+
externalConversationId: normalized.externalConversationId,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// The assertion the old version could not make: the hub composed a real
|
|
151
|
+
// platform message from a sender with no address. Before #4975 this step
|
|
152
|
+
// failed `externalEmail is required when visibility is public`, retried
|
|
153
|
+
// three times and died in the queue while the channel still read
|
|
154
|
+
// `Connected`.
|
|
155
|
+
expect(
|
|
156
|
+
ingested.status,
|
|
157
|
+
'the hub must accept an inbound message whose sender has no email address',
|
|
158
|
+
).toBe('created')
|
|
159
|
+
expect(ingested.messageId, 'a platform message must exist').toBeTruthy()
|
|
160
|
+
expect(ingested.channelLinkId, 'the hub must persist a MessageChannelLink').toBeTruthy()
|
|
161
|
+
expect(
|
|
162
|
+
ingested.channelType,
|
|
163
|
+
'the compose must have been told the originating channel type',
|
|
164
|
+
).not.toBe('email')
|
|
165
|
+
|
|
166
|
+
const after = await readHealth(request, token, channelId)
|
|
167
|
+
expect(
|
|
168
|
+
after.totalsLast24h ?? 0,
|
|
169
|
+
'the channel health snapshot must count the ingested message',
|
|
170
|
+
).toBeGreaterThan(totalBefore)
|
|
171
|
+
} finally {
|
|
172
|
+
if (token && channelId) await deleteChannelIfExists(request, token, channelId)
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
})
|