@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,174 @@
|
|
|
1
|
+
import { createPublicKey, verify as cryptoVerify } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Discord interaction request signing (Ed25519).
|
|
5
|
+
*
|
|
6
|
+
* Every interaction POST carries `X-Signature-Ed25519` (hex) and
|
|
7
|
+
* `X-Signature-Timestamp`. The signed payload is `timestamp + rawBody`, verified
|
|
8
|
+
* against the application's Ed25519 **public key** (hex, from the General
|
|
9
|
+
* Information tab). We verify with Node's built-in `crypto` (`ed25519`) — no
|
|
10
|
+
* `tweetnacl` dependency.
|
|
11
|
+
*
|
|
12
|
+
* SECURITY CONTRACT: this function is FAIL-CLOSED. It returns `false` on a
|
|
13
|
+
* missing header, a malformed key/signature, or any verification error — never
|
|
14
|
+
* throws, never returns `true` on doubt. Callers MUST reject (`401`) on `false`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Standard SPKI DER prefix for an Ed25519 public key (RFC 8410). Prepending it
|
|
18
|
+
// to the 32 raw key bytes yields a DER document `createPublicKey` accepts,
|
|
19
|
+
// avoiding a third-party crypto dependency.
|
|
20
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
|
21
|
+
|
|
22
|
+
function isHex(value: string): boolean {
|
|
23
|
+
return value.length > 0 && value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An Ed25519 signature is 64 bytes, so 128 hex characters. */
|
|
27
|
+
const ED25519_SIGNATURE_HEX_LENGTH = 128
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether the request's signature headers are even capable of verifying —
|
|
31
|
+
* present, hex, and the right length. This depends on the REQUEST ALONE, never
|
|
32
|
+
* on a candidate channel, which is why it is exported separately: the route runs
|
|
33
|
+
* it before it loads any channel, so an unsigned or malformed POST is rejected
|
|
34
|
+
* without a single database round-trip.
|
|
35
|
+
*
|
|
36
|
+
* `verifyDiscordSignature` applies the same guard itself, so this is an
|
|
37
|
+
* optimisation of *when* the answer is known, never a replacement for the
|
|
38
|
+
* fail-closed check.
|
|
39
|
+
*/
|
|
40
|
+
export function hasVerifiableSignatureHeaders(
|
|
41
|
+
signatureHex: string | undefined | null,
|
|
42
|
+
timestamp: string | undefined | null,
|
|
43
|
+
): signatureHex is string {
|
|
44
|
+
if (!signatureHex || !timestamp) return false
|
|
45
|
+
return isHex(signatureHex) && signatureHex.length === ED25519_SIGNATURE_HEX_LENGTH
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function publicKeyFromHex(publicKeyHex: string) {
|
|
49
|
+
if (!isHex(publicKeyHex) || publicKeyHex.length !== 64) {
|
|
50
|
+
throw new Error('invalid ed25519 public key')
|
|
51
|
+
}
|
|
52
|
+
const raw = Buffer.from(publicKeyHex, 'hex')
|
|
53
|
+
const der = Buffer.concat([ED25519_SPKI_PREFIX, raw])
|
|
54
|
+
return createPublicKey({ key: der, format: 'der', type: 'spki' })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface VerifyDiscordSignatureInput {
|
|
58
|
+
publicKeyHex: string
|
|
59
|
+
signatureHex: string | undefined | null
|
|
60
|
+
timestamp: string | undefined | null
|
|
61
|
+
rawBody: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Verify a Discord interaction signature. Returns `true` only when the signature
|
|
66
|
+
* cryptographically matches `timestamp + rawBody` under `publicKeyHex`.
|
|
67
|
+
*/
|
|
68
|
+
export function verifyDiscordSignature(input: VerifyDiscordSignatureInput): boolean {
|
|
69
|
+
const { publicKeyHex, signatureHex, timestamp, rawBody } = input
|
|
70
|
+
if (!hasVerifiableSignatureHeaders(signatureHex, timestamp)) return false
|
|
71
|
+
try {
|
|
72
|
+
const key = publicKeyFromHex(publicKeyHex)
|
|
73
|
+
const message = Buffer.from(String(timestamp) + rawBody, 'utf-8')
|
|
74
|
+
const signature = Buffer.from(signatureHex, 'hex')
|
|
75
|
+
// Ed25519: the algorithm is implied by the key, so the first arg is null.
|
|
76
|
+
return cryptoVerify(null, message, key, signature)
|
|
77
|
+
} catch {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Replay guard. Discord signs `timestamp + body`, but a captured request stays
|
|
84
|
+
* cryptographically valid forever — freshness has to be enforced separately.
|
|
85
|
+
* A timestamp outside the ± skew window (or missing / non-numeric) is rejected,
|
|
86
|
+
* fail-closed, so a recorded interaction cannot be replayed later.
|
|
87
|
+
*/
|
|
88
|
+
export const DISCORD_SIGNATURE_MAX_SKEW_SECONDS = 300
|
|
89
|
+
|
|
90
|
+
export interface TimestampFreshnessOptions {
|
|
91
|
+
maxSkewSeconds?: number
|
|
92
|
+
/** Injectable clock (epoch seconds) for tests; defaults to the real clock. */
|
|
93
|
+
nowEpochSeconds?: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isSignatureTimestampFresh(
|
|
97
|
+
timestamp: string | undefined | null,
|
|
98
|
+
options?: TimestampFreshnessOptions,
|
|
99
|
+
): boolean {
|
|
100
|
+
if (!timestamp) return false
|
|
101
|
+
const value = String(timestamp).trim()
|
|
102
|
+
if (!/^\d{1,12}$/.test(value)) return false
|
|
103
|
+
const parsedSeconds = Number(value)
|
|
104
|
+
if (!Number.isSafeInteger(parsedSeconds)) return false
|
|
105
|
+
const maxSkewSeconds = options?.maxSkewSeconds ?? DISCORD_SIGNATURE_MAX_SKEW_SECONDS
|
|
106
|
+
const nowSeconds = options?.nowEpochSeconds ?? Math.floor(Date.now() / 1000)
|
|
107
|
+
return Math.abs(nowSeconds - parsedSeconds) <= maxSkewSeconds
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Discord interaction types. */
|
|
111
|
+
export const DiscordInteractionType = {
|
|
112
|
+
PING: 1,
|
|
113
|
+
APPLICATION_COMMAND: 2,
|
|
114
|
+
MESSAGE_COMPONENT: 3,
|
|
115
|
+
APPLICATION_COMMAND_AUTOCOMPLETE: 4,
|
|
116
|
+
MODAL_SUBMIT: 5,
|
|
117
|
+
} as const
|
|
118
|
+
|
|
119
|
+
/** Discord interaction response types. */
|
|
120
|
+
export const DiscordInteractionResponseType = {
|
|
121
|
+
PONG: 1,
|
|
122
|
+
CHANNEL_MESSAGE_WITH_SOURCE: 4,
|
|
123
|
+
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE: 5,
|
|
124
|
+
DEFERRED_UPDATE_MESSAGE: 6,
|
|
125
|
+
// Autocomplete is the one interaction type Discord will not accept a deferred
|
|
126
|
+
// ack for — it must be answered synchronously with this type and a choice list.
|
|
127
|
+
APPLICATION_COMMAND_AUTOCOMPLETE_RESULT: 8,
|
|
128
|
+
} as const
|
|
129
|
+
|
|
130
|
+
export interface ParsedInteraction {
|
|
131
|
+
type: number
|
|
132
|
+
data?: Record<string, unknown>
|
|
133
|
+
member?: { user?: { id?: string; username?: string; global_name?: string | null } }
|
|
134
|
+
user?: { id?: string; username?: string; global_name?: string | null }
|
|
135
|
+
channel_id?: string
|
|
136
|
+
guild_id?: string
|
|
137
|
+
application_id?: string
|
|
138
|
+
id?: string
|
|
139
|
+
token?: string
|
|
140
|
+
[key: string]: unknown
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parse the interaction body. Returns `null` when the body is not a JSON object
|
|
145
|
+
* with a numeric `type` — the caller treats that as a non-interaction payload.
|
|
146
|
+
*/
|
|
147
|
+
export function parseInteractionBody(rawBody: string): ParsedInteraction | null {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(rawBody) as unknown
|
|
150
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
151
|
+
const candidate = parsed as { type?: unknown }
|
|
152
|
+
if (typeof candidate.type !== 'number') return null
|
|
153
|
+
return parsed as ParsedInteraction
|
|
154
|
+
} catch {
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The `application_id` an interaction body claims, or `null` when the body does
|
|
161
|
+
* not carry a usable one.
|
|
162
|
+
*
|
|
163
|
+
* UNTRUSTED — this value is read before any signature has been verified, so it
|
|
164
|
+
* may only ever be used to NARROW the set of candidate channels the signature is
|
|
165
|
+
* then checked against. It is never an authorization decision: a body claiming
|
|
166
|
+
* another tenant's application still has to carry that tenant's Ed25519
|
|
167
|
+
* signature to be accepted, and a body claiming nothing falls back to the full
|
|
168
|
+
* candidate set rather than being let through.
|
|
169
|
+
*/
|
|
170
|
+
export function readInteractionApplicationId(rawBody: string): string | null {
|
|
171
|
+
const interaction = parseInteractionBody(rawBody)
|
|
172
|
+
const applicationId = interaction?.application_id
|
|
173
|
+
return typeof applicationId === 'string' && applicationId.length > 0 ? applicationId : null
|
|
174
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NormalizedInboundMessage,
|
|
3
|
+
NormalizedAttachment,
|
|
4
|
+
} from '@open-mercato/core/modules/communication_channels/lib/adapter'
|
|
5
|
+
import type { DiscordMessageObject } from './discord-rest'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Prefix used to build the hub's `externalConversationId` from a Discord channel
|
|
9
|
+
* (or thread) id. A Discord "channel" (text channel / thread) is the unit the
|
|
10
|
+
* hub threads on, mirroring `gmail-thread:` / Slack channel conventions.
|
|
11
|
+
*/
|
|
12
|
+
export const DISCORD_CONVERSATION_PREFIX = 'discord-channel:'
|
|
13
|
+
|
|
14
|
+
export const DISCORD_CHANNEL_CONTENT_TYPE = 'discord'
|
|
15
|
+
|
|
16
|
+
function mapAttachments(message: DiscordMessageObject): NormalizedAttachment[] | undefined {
|
|
17
|
+
if (!Array.isArray(message.attachments) || message.attachments.length === 0) return undefined
|
|
18
|
+
return message.attachments.map((attachment) => ({
|
|
19
|
+
url: attachment.url,
|
|
20
|
+
mimeType: attachment.content_type ?? 'application/octet-stream',
|
|
21
|
+
fileName: attachment.filename,
|
|
22
|
+
fileSize: attachment.size,
|
|
23
|
+
inline: false,
|
|
24
|
+
}))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Map a raw Discord message object (from `MESSAGE_CREATE` or REST history) into
|
|
29
|
+
* the hub's canonical `NormalizedInboundMessage`.
|
|
30
|
+
*
|
|
31
|
+
* Discord senders have no email/phone — the CRM contact is resolved from the
|
|
32
|
+
* user id / handle later (`resolveContact`). The `externalConversationId` is the
|
|
33
|
+
* channel/thread id (a Discord thread reuses this same shape), so all messages
|
|
34
|
+
* in one channel thread into one hub conversation.
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeInboundDiscordMessage(message: DiscordMessageObject): NormalizedInboundMessage {
|
|
37
|
+
const author = message.author ?? ({} as DiscordMessageObject['author'])
|
|
38
|
+
const senderDisplayName = author?.global_name || author?.username || undefined
|
|
39
|
+
const timestamp = message.timestamp ? new Date(message.timestamp) : new Date()
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
externalMessageId: message.id,
|
|
43
|
+
externalConversationId: `${DISCORD_CONVERSATION_PREFIX}${message.channel_id}`,
|
|
44
|
+
senderIdentifier: author?.id ?? 'unknown',
|
|
45
|
+
senderDisplayName,
|
|
46
|
+
body: message.content ?? '',
|
|
47
|
+
bodyFormat: 'markdown',
|
|
48
|
+
attachments: mapAttachments(message),
|
|
49
|
+
timestamp,
|
|
50
|
+
replyToExternalId: message.message_reference?.message_id,
|
|
51
|
+
channelPayload: message as unknown as Record<string, unknown>,
|
|
52
|
+
channelContentType: DISCORD_CHANNEL_CONTENT_TYPE,
|
|
53
|
+
channelMetadata: {
|
|
54
|
+
discordChannelId: message.channel_id,
|
|
55
|
+
discordGuildId: message.guild_id,
|
|
56
|
+
discordAuthorId: author?.id,
|
|
57
|
+
discordAuthorUsername: author?.username,
|
|
58
|
+
discordAuthorGlobalName: author?.global_name ?? undefined,
|
|
59
|
+
discordAuthorIsBot: Boolean(author?.bot),
|
|
60
|
+
// Only present when the record came from a slash command or a component
|
|
61
|
+
// press rather than someone typing. A consumer that treats every inbound
|
|
62
|
+
// message the same can ignore these; one that answers commands
|
|
63
|
+
// specifically needs to tell them apart without re-parsing `channelPayload`.
|
|
64
|
+
...(message.discord_interaction
|
|
65
|
+
? {
|
|
66
|
+
discordInteractionId: message.discord_interaction.id,
|
|
67
|
+
discordInteractionType: message.discord_interaction.type,
|
|
68
|
+
discordCommandName: message.discord_interaction.commandName,
|
|
69
|
+
discordComponentCustomId: message.discord_interaction.customId,
|
|
70
|
+
}
|
|
71
|
+
: {}),
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* True when a raw message must be dropped as bot-authored. The gateway worker
|
|
78
|
+
* uses this as the feedback-loop guard.
|
|
79
|
+
*
|
|
80
|
+
* We drop ANY message flagged `author.bot` (not just our own bot's user id):
|
|
81
|
+
* other bots/webhooks in the channel would otherwise be ingested and could
|
|
82
|
+
* trigger the AI auto-reply, creating cross-bot loops and noise. The explicit
|
|
83
|
+
* `botUserId` check is retained as a belt-and-suspenders guard for the rare case
|
|
84
|
+
* where our own bot's `author.bot` flag is absent from a payload.
|
|
85
|
+
*/
|
|
86
|
+
export function isAuthoredByBot(message: DiscordMessageObject, botUserId: string | undefined): boolean {
|
|
87
|
+
if (message.author?.bot) return true
|
|
88
|
+
return botUserId ? message.author?.id === botUserId : false
|
|
89
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
2
|
+
import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
|
|
3
|
+
import type { CredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'
|
|
4
|
+
import type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
|
|
5
|
+
import { discordCredentialsSchema, type DiscordCredentials } from './credentials'
|
|
6
|
+
|
|
7
|
+
export const CHANNEL_DISCORD_INTEGRATION_ID = 'channel_discord'
|
|
8
|
+
|
|
9
|
+
export type DiscordEnvPreset = {
|
|
10
|
+
credentials: DiscordCredentials
|
|
11
|
+
force: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ApplyDiscordPresetResult =
|
|
15
|
+
| { status: 'skipped'; reason: string }
|
|
16
|
+
| { status: 'configured' }
|
|
17
|
+
|
|
18
|
+
function readEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
|
19
|
+
const value = env[key]?.trim()
|
|
20
|
+
return value ? value : undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the deployment-managed Discord bootstrap credentials from the environment
|
|
25
|
+
* (SPEC 2026-06-19 § Connect the channel in Open Mercato).
|
|
26
|
+
*
|
|
27
|
+
* Returns `null` when no `OM_CHANNEL_DISCORD_*` credential var is set at all —
|
|
28
|
+
* the normal case, where the bot is connected interactively through
|
|
29
|
+
* `/backend/integrations`. When any of them IS set the preset must be complete,
|
|
30
|
+
* so a half-filled deployment fails loudly instead of persisting a bot token
|
|
31
|
+
* that cannot authenticate. The bot token is never logged or echoed.
|
|
32
|
+
*/
|
|
33
|
+
export function readDiscordEnvPreset(env: NodeJS.ProcessEnv = process.env): DiscordEnvPreset | null {
|
|
34
|
+
const botToken = readEnvValue(env, 'OM_CHANNEL_DISCORD_BOT_TOKEN')
|
|
35
|
+
const applicationId = readEnvValue(env, 'OM_CHANNEL_DISCORD_APPLICATION_ID')
|
|
36
|
+
const publicKey = readEnvValue(env, 'OM_CHANNEL_DISCORD_PUBLIC_KEY')
|
|
37
|
+
const guildId = readEnvValue(env, 'OM_CHANNEL_DISCORD_GUILD_ID')
|
|
38
|
+
const defaultChannelId = readEnvValue(env, 'OM_CHANNEL_DISCORD_DEFAULT_CHANNEL_ID')
|
|
39
|
+
|
|
40
|
+
if (!botToken && !applicationId && !publicKey) return null
|
|
41
|
+
|
|
42
|
+
const parsed = discordCredentialsSchema.safeParse({
|
|
43
|
+
botToken,
|
|
44
|
+
applicationId,
|
|
45
|
+
publicKey,
|
|
46
|
+
...(guildId ? { guildId } : {}),
|
|
47
|
+
...(defaultChannelId ? { defaultChannelId } : {}),
|
|
48
|
+
})
|
|
49
|
+
if (!parsed.success) {
|
|
50
|
+
const first = parsed.error.issues[0]
|
|
51
|
+
throw new Error(
|
|
52
|
+
'[internal] Incomplete Discord env preset. Set OM_CHANNEL_DISCORD_BOT_TOKEN, ' +
|
|
53
|
+
'OM_CHANNEL_DISCORD_APPLICATION_ID and OM_CHANNEL_DISCORD_PUBLIC_KEY ' +
|
|
54
|
+
`(${first?.path.join('.') ?? 'credentials'}: ${first?.message ?? 'invalid'}).`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
credentials: parsed.data,
|
|
60
|
+
force: parseBooleanToken(env.OM_CHANNEL_DISCORD_FORCE_PRECONFIGURE) ?? false,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Apply the env preset to one (tenant, organization) scope through the standard
|
|
66
|
+
* integration credential service — never by special-casing the provider in core.
|
|
67
|
+
*
|
|
68
|
+
* Existing credentials win unless `OM_CHANNEL_DISCORD_FORCE_PRECONFIGURE` (or an
|
|
69
|
+
* explicit `force`) says otherwise, so re-running tenant setup or the
|
|
70
|
+
* `configure-from-env` CLI command is safe and idempotent.
|
|
71
|
+
*/
|
|
72
|
+
export async function applyDiscordEnvPreset(params: {
|
|
73
|
+
credentialsService: CredentialsService
|
|
74
|
+
integrationLogService?: IntegrationLogService
|
|
75
|
+
scope: IntegrationScope
|
|
76
|
+
force?: boolean
|
|
77
|
+
env?: NodeJS.ProcessEnv
|
|
78
|
+
}): Promise<ApplyDiscordPresetResult> {
|
|
79
|
+
const preset = readDiscordEnvPreset(params.env)
|
|
80
|
+
if (!preset) {
|
|
81
|
+
return { status: 'skipped', reason: 'No Discord preset env variables were provided.' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const force = params.force ?? preset.force
|
|
85
|
+
if (!force) {
|
|
86
|
+
const existing = await params.credentialsService.getRaw(CHANNEL_DISCORD_INTEGRATION_ID, params.scope)
|
|
87
|
+
if (existing) {
|
|
88
|
+
return {
|
|
89
|
+
status: 'skipped',
|
|
90
|
+
reason:
|
|
91
|
+
'Discord credentials already exist. Use OM_CHANNEL_DISCORD_FORCE_PRECONFIGURE=true to overwrite.',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await params.credentialsService.save(CHANNEL_DISCORD_INTEGRATION_ID, preset.credentials, params.scope)
|
|
97
|
+
|
|
98
|
+
if (params.integrationLogService) {
|
|
99
|
+
await params.integrationLogService
|
|
100
|
+
.scoped(CHANNEL_DISCORD_INTEGRATION_ID, params.scope)
|
|
101
|
+
.info('Discord integration was preconfigured from environment variables.', {
|
|
102
|
+
applicationId: preset.credentials.applicationId,
|
|
103
|
+
guildId: preset.credentials.guildId ?? null,
|
|
104
|
+
defaultChannelId: preset.credentials.defaultChannelId ?? null,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { status: 'configured' }
|
|
109
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
2
|
+
|
|
3
|
+
const logger = createLogger('channel_discord').child({ component: 'cli' })
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Refuse to start the gateway when inbound messages cannot possibly be delivered.
|
|
7
|
+
*
|
|
8
|
+
* `start-gateway` is a standalone process that runs a worker for the
|
|
9
|
+
* `channel_discord_gateway` queue only, while the bridge enqueues received
|
|
10
|
+
* messages into `communication-channels-inbound` / `-reactions`, whose workers
|
|
11
|
+
* live in the app server process. Under the DEFAULT `QUEUE_STRATEGY=local` the
|
|
12
|
+
* queue is in-process, so those jobs never cross the process boundary: they are
|
|
13
|
+
* dropped with no error, no log, no retry and no `failed` entry, while the
|
|
14
|
+
* channel keeps reporting `Connected`. Failing loudly at startup is the only
|
|
15
|
+
* honest option — the alternative looks like it works.
|
|
16
|
+
*
|
|
17
|
+
* Lives outside `cli.ts` so it stays unit-testable without pulling the DI
|
|
18
|
+
* container graph into the test.
|
|
19
|
+
*/
|
|
20
|
+
export function assertInboundDeliverable(queueStrategy: string | undefined): void {
|
|
21
|
+
if (queueStrategy === 'async') return
|
|
22
|
+
const message =
|
|
23
|
+
'channel_discord start-gateway requires QUEUE_STRATEGY=async (with Redis configured). ' +
|
|
24
|
+
'Under the default in-process queue strategy the gateway cannot hand inbound messages to the ' +
|
|
25
|
+
'communication-channels workers running in the app server process — they would be silently dropped. ' +
|
|
26
|
+
'Set QUEUE_STRATEGY=async and point the queue at Redis, then start this command again.'
|
|
27
|
+
logger.error(message, { queueStrategy: queueStrategy ?? 'local (default)' })
|
|
28
|
+
throw new Error(`[internal] ${message}`)
|
|
29
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application (slash) commands this provider registers against a guild.
|
|
3
|
+
*
|
|
4
|
+
* NOT LOCALIZED, deliberately: a Discord application registers ONE canonical
|
|
5
|
+
* command name per guild — the localized variants Discord supports live in
|
|
6
|
+
* `name_localizations` / `description_localizations`, which are a per-command
|
|
7
|
+
* map rather than an app locale, and no operator surface exists yet to choose
|
|
8
|
+
* which locales a tenant wants. The registration is also an operator action
|
|
9
|
+
* (`mercato channel_discord register-slash-commands`), not a rendered UI string.
|
|
10
|
+
*
|
|
11
|
+
* The set is deliberately minimal. Every command routes to the same place —
|
|
12
|
+
* the hub inbox — so the provider ships the one command that makes that useful
|
|
13
|
+
* and leaves richer command trees to whoever knows their own workflow. Register
|
|
14
|
+
* a different set by passing `--commands <path-to-json>`.
|
|
15
|
+
*/
|
|
16
|
+
export const CHANNEL_DISCORD_SLASH_COMMAND_TYPE_CHAT_INPUT = 1
|
|
17
|
+
export const CHANNEL_DISCORD_OPTION_TYPE_STRING = 3
|
|
18
|
+
|
|
19
|
+
export const DISCORD_DEFAULT_SLASH_COMMANDS: Array<Record<string, unknown>> = [
|
|
20
|
+
{
|
|
21
|
+
name: 'mercato',
|
|
22
|
+
type: CHANNEL_DISCORD_SLASH_COMMAND_TYPE_CHAT_INPUT,
|
|
23
|
+
description: 'Send a message to the team through Open Mercato',
|
|
24
|
+
options: [
|
|
25
|
+
{
|
|
26
|
+
type: CHANNEL_DISCORD_OPTION_TYPE_STRING,
|
|
27
|
+
name: 'message',
|
|
28
|
+
description: 'What do you need?',
|
|
29
|
+
required: true,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate an operator-supplied command list read from JSON. Discord rejects a
|
|
37
|
+
* malformed registration with a 400 whose body names an array index, so failing
|
|
38
|
+
* here with the index and the reason is strictly more useful than forwarding it.
|
|
39
|
+
*/
|
|
40
|
+
export function parseSlashCommandDefinitions(value: unknown): Array<Record<string, unknown>> {
|
|
41
|
+
if (!Array.isArray(value)) {
|
|
42
|
+
throw new Error('[internal] Discord slash command definitions must be a JSON array.')
|
|
43
|
+
}
|
|
44
|
+
return value.map((entry, index) => {
|
|
45
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
46
|
+
throw new Error(`[internal] Discord slash command at index ${index} is not an object.`)
|
|
47
|
+
}
|
|
48
|
+
const command = entry as Record<string, unknown>
|
|
49
|
+
if (typeof command.name !== 'string' || command.name.length === 0) {
|
|
50
|
+
throw new Error(`[internal] Discord slash command at index ${index} has no name.`)
|
|
51
|
+
}
|
|
52
|
+
if (typeof command.description !== 'string' || command.description.length === 0) {
|
|
53
|
+
throw new Error(`[internal] Discord slash command "${command.name}" has no description.`)
|
|
54
|
+
}
|
|
55
|
+
return command
|
|
56
|
+
})
|
|
57
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { MessageAction, MessageTypeDefinition } from '@open-mercato/shared/modules/messages/types'
|
|
2
|
+
import {
|
|
3
|
+
CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
|
|
4
|
+
CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,
|
|
5
|
+
} from './lib/ai-proposal-contract'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The message type the `complex`-tier AI proposal is filed under (issue #4778).
|
|
9
|
+
*
|
|
10
|
+
* Declaring it here is what makes the approval surface work at all: the messages
|
|
11
|
+
* module builds its allowlist of dispatchable action commands from the
|
|
12
|
+
* `defaultActions` of every REGISTERED message type
|
|
13
|
+
* (`messages/lib/actions.ts` → `getMessageSafeCommandIds`). An action whose
|
|
14
|
+
* `commandId` is not reachable that way is rejected with "Action command is not
|
|
15
|
+
* allowed" — the confused-deputy guard that stops a composer from making a
|
|
16
|
+
* recipient dispatch an arbitrary command with their own auth. So the two
|
|
17
|
+
* commands below are allowlisted precisely because this file declares them, and
|
|
18
|
+
* for no other message type.
|
|
19
|
+
*/
|
|
20
|
+
export const CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE = 'channel_discord.ai_reply_proposal'
|
|
21
|
+
|
|
22
|
+
export const CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_ACTION_ID = 'approve_send'
|
|
23
|
+
export const CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_ACTION_ID = 'dismiss'
|
|
24
|
+
|
|
25
|
+
const proposalActions: MessageAction[] = [
|
|
26
|
+
{
|
|
27
|
+
id: CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_ACTION_ID,
|
|
28
|
+
label: 'Approve and send',
|
|
29
|
+
labelKey: 'channel_discord.aiProposal.actions.approve',
|
|
30
|
+
variant: 'default',
|
|
31
|
+
icon: 'send',
|
|
32
|
+
commandId: CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
|
|
33
|
+
isTerminal: true,
|
|
34
|
+
// Sending posts publicly into a Discord server on the tenant's behalf, so the
|
|
35
|
+
// operator confirms once before it leaves the building. No `confirmMessage`:
|
|
36
|
+
// the inbox renders that field verbatim (`message-detail/panels/dialogs.tsx`),
|
|
37
|
+
// so a translation key would leak into the dialog — omitting it falls back to
|
|
38
|
+
// the module's own localized confirmation copy.
|
|
39
|
+
confirmRequired: true,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_ACTION_ID,
|
|
43
|
+
label: 'Dismiss',
|
|
44
|
+
labelKey: 'channel_discord.aiProposal.actions.dismiss',
|
|
45
|
+
variant: 'outline',
|
|
46
|
+
icon: 'x',
|
|
47
|
+
commandId: CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,
|
|
48
|
+
isTerminal: true,
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
export const messageTypes: MessageTypeDefinition[] = [
|
|
53
|
+
{
|
|
54
|
+
type: CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE,
|
|
55
|
+
module: 'channel_discord',
|
|
56
|
+
labelKey: 'channel_discord.aiProposal.messageType',
|
|
57
|
+
icon: 'bot',
|
|
58
|
+
color: 'violet',
|
|
59
|
+
ui: {
|
|
60
|
+
listItemComponent: 'messages.default.listItem',
|
|
61
|
+
contentComponent: 'messages.default.content',
|
|
62
|
+
actionsComponent: 'messages.default.actions',
|
|
63
|
+
},
|
|
64
|
+
// Replying to the proposal itself would go nowhere — the conversation lives
|
|
65
|
+
// on the Discord thread the proposal points at, not on this internal note.
|
|
66
|
+
allowReply: false,
|
|
67
|
+
allowForward: true,
|
|
68
|
+
defaultActions: proposalActions,
|
|
69
|
+
// A week-old proposal is stale: the person on Discord has moved on and the
|
|
70
|
+
// draft was written against a conversation that has since changed.
|
|
71
|
+
actionsExpireAfterHours: 168,
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
export default messageTypes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
|
|
2
|
+
import {
|
|
3
|
+
hasChannelAdapter,
|
|
4
|
+
registerChannelAdapter,
|
|
5
|
+
} from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
|
|
6
|
+
import { createCredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'
|
|
7
|
+
import { createIntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
|
|
8
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
9
|
+
import { getDiscordChannelAdapter } from './lib/adapter'
|
|
10
|
+
import { applyDiscordEnvPreset, CHANNEL_DISCORD_INTEGRATION_ID } from './lib/preset'
|
|
11
|
+
|
|
12
|
+
const logger = createLogger('channel_discord').child({ component: 'setup' })
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register the Discord `ChannelAdapter` exactly once per process at import time.
|
|
16
|
+
* The registry is process-wide, so we guard with `hasChannelAdapter` to silence
|
|
17
|
+
* the duplicate-providerKey error on dev-mode HMR + repeated test imports.
|
|
18
|
+
*
|
|
19
|
+
* Env presets (`OM_CHANNEL_DISCORD_*`, see `lib/preset.ts` and `.env.example`)
|
|
20
|
+
* are optional operator conveniences applied on tenant creation and rerunnable
|
|
21
|
+
* via `yarn mercato channel_discord configure-from-env`; the bot is normally
|
|
22
|
+
* connected via the credential connect flow (`/backend/integrations`). The
|
|
23
|
+
* gateway worker honours `OM_CHANNEL_DISCORD_GATEWAY_DISABLED` to skip the
|
|
24
|
+
* socket in CI / send-only deployments.
|
|
25
|
+
*/
|
|
26
|
+
function ensureDiscordAdapterRegistered(): void {
|
|
27
|
+
if (hasChannelAdapter('discord')) return
|
|
28
|
+
registerChannelAdapter(getDiscordChannelAdapter())
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ensureDiscordAdapterRegistered()
|
|
32
|
+
|
|
33
|
+
export const setup: ModuleSetupConfig = {
|
|
34
|
+
defaultRoleFeatures: {
|
|
35
|
+
// `ai_auto_reply.run` is granted here so a tenant that creates a real
|
|
36
|
+
// channel-bot user and puts it in an admin role gets a principal that can
|
|
37
|
+
// invoke the auto-reply agent. Deployments without such a user do not need
|
|
38
|
+
// the grant at all — the provider's service principal carries it in code
|
|
39
|
+
// (`lib/ai-service-principal.ts`).
|
|
40
|
+
superadmin: [
|
|
41
|
+
'channel_discord.view',
|
|
42
|
+
'channel_discord.configure',
|
|
43
|
+
'channel_discord.ai_auto_reply.run',
|
|
44
|
+
],
|
|
45
|
+
admin: [
|
|
46
|
+
'channel_discord.view',
|
|
47
|
+
'channel_discord.configure',
|
|
48
|
+
'channel_discord.ai_auto_reply.run',
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
async onTenantCreated({ em, organizationId, tenantId }) {
|
|
52
|
+
ensureDiscordAdapterRegistered()
|
|
53
|
+
|
|
54
|
+
const integrationLogService = createIntegrationLogService(em)
|
|
55
|
+
try {
|
|
56
|
+
await applyDiscordEnvPreset({
|
|
57
|
+
credentialsService: createCredentialsService(em),
|
|
58
|
+
integrationLogService,
|
|
59
|
+
scope: { tenantId, organizationId },
|
|
60
|
+
})
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : 'Unknown Discord preset error'
|
|
63
|
+
try {
|
|
64
|
+
await integrationLogService
|
|
65
|
+
.scoped(CHANNEL_DISCORD_INTEGRATION_ID, { tenantId, organizationId })
|
|
66
|
+
.error(`Failed to apply Discord env preset during tenant setup: ${message}`)
|
|
67
|
+
} catch (logError) {
|
|
68
|
+
const logMessage = logError instanceof Error ? logError.message : 'Unknown integration log error'
|
|
69
|
+
logger.error('Failed to apply env preset during tenant setup; persisting to integration logs also failed', {
|
|
70
|
+
presetError: message,
|
|
71
|
+
logError: logMessage,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default setup
|