@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,104 @@
|
|
|
1
|
+
import type { ChannelCapabilities } from '@open-mercato/core/modules/communication_channels/lib/adapter'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Discord message content hard limit — the REST API rejects `content` longer
|
|
5
|
+
* than 2000 characters (embeds have their own separate budget we don't use here).
|
|
6
|
+
*/
|
|
7
|
+
export const DISCORD_MAX_BODY_LENGTH = 2000
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Discord capability profile (SPEC 2026-06-19 § Adapter method map).
|
|
11
|
+
*
|
|
12
|
+
* The hub treats these flags as a contract: it routes work to the adapter based
|
|
13
|
+
* on them, so each one describes what THIS adapter implements today, not what
|
|
14
|
+
* the Discord API is able to do. Everything the first release does not implement
|
|
15
|
+
* is declared `false` and stays `false` until the corresponding code lands.
|
|
16
|
+
*
|
|
17
|
+
* Enabled, with the implementation behind each:
|
|
18
|
+
* - `richText` / `supportedBodyFormats: ['text', 'markdown']` — Discord content
|
|
19
|
+
* is markdown-native; HTML is down-converted in `convertOutbound`.
|
|
20
|
+
* - `reactions` / `editMessage` / `deleteMessage` — backed by the matching
|
|
21
|
+
* `discord-rest` calls the adapter exposes.
|
|
22
|
+
* - `conversationHistory` — `fetchHistory` pages `GET /channels/{id}/messages`.
|
|
23
|
+
* - `realtimePush` — the provider owns a long-running Gateway WebSocket worker
|
|
24
|
+
* that delivers `MESSAGE_CREATE` / reaction events in real time, so the hub's
|
|
25
|
+
* polling scheduler skips this channel (no redundant `fetchHistory`).
|
|
26
|
+
* - `interactiveComponents` — slash commands, buttons, select menus and modal
|
|
27
|
+
* submissions round-trip for real: the signed Interactions endpoint answers
|
|
28
|
+
* with a deferred acknowledgement AND hands the interaction to
|
|
29
|
+
* `workers/discord-interactions.ts`, which normalizes it into the hub's
|
|
30
|
+
* existing inbound queue under the matched channel's tenant scope and then
|
|
31
|
+
* replaces the "thinking…" placeholder over Discord's interaction-webhook
|
|
32
|
+
* endpoints. Pinned by the parity test in `lib/__tests__/capabilities.test.ts`,
|
|
33
|
+
* which drives the whole path rather than asserting the flag alone.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately disabled until implemented (declaring them would make the hub
|
|
36
|
+
* hand this adapter work it silently drops):
|
|
37
|
+
* - `threading` — `convertOutbound` reads `channelMetadata.replyToExternalId`
|
|
38
|
+
* and emits `message_reference` from it, but nothing hub-side ever writes that
|
|
39
|
+
* key into OUTBOUND metadata: `replyToExternalId` exists only on the inbound
|
|
40
|
+
* `NormalizedInboundMessage` shape, and the hub's outbound metadata producers
|
|
41
|
+
* (`send-as-user.ts`, `deliver-outbound-message.ts`) write the email-shaped
|
|
42
|
+
* `inReplyTo` / `references` instead. The conversion is therefore unreachable
|
|
43
|
+
* in production — confirmed against a live bot in #5541. The flag flips back
|
|
44
|
+
* to `true` in the same change that gives the hub an outbound reply producer
|
|
45
|
+
* this adapter can read, guarded by a contract test.
|
|
46
|
+
* - `fileSharing` / `inlineImages` — `convertOutbound` drops
|
|
47
|
+
* `input.content.attachments` and `discord-rest` has no multipart upload, so
|
|
48
|
+
* outbound attachments never reach Discord. `maxFileSize` /
|
|
49
|
+
* `supportedMimeTypes` are omitted for the same reason.
|
|
50
|
+
* - `typingIndicators` — no `POST /channels/{id}/typing` call exists here.
|
|
51
|
+
* - `presence` — the bot identifies without the `GUILD_PRESENCES` intent and no
|
|
52
|
+
* presence dispatch is handled.
|
|
53
|
+
* - `richBlocks` — outbound is plain markdown `content`; embeds are not built.
|
|
54
|
+
* - `stickers` — no sticker is sent or normalized.
|
|
55
|
+
*
|
|
56
|
+
* AI auto-reply (issue #4778) deliberately flips NONE of these back to `true`.
|
|
57
|
+
* `ChannelCapabilities` describes what the hub may hand this ADAPTER — message
|
|
58
|
+
* shapes, transport features, whether to schedule polling. Auto-reply is not an
|
|
59
|
+
* adapter capability at all: it is a subscriber on the hub's generic
|
|
60
|
+
* `message.received` event that composes a reply through the same outbound path
|
|
61
|
+
* any module uses. Adding a flag for it would tell the hub to route work here
|
|
62
|
+
* that the adapter does not implement, which is exactly the failure mode this
|
|
63
|
+
* list exists to prevent.
|
|
64
|
+
*/
|
|
65
|
+
export const discordCapabilities: ChannelCapabilities = {
|
|
66
|
+
// A Discord recipient is a channel snowflake, never an address. Without this
|
|
67
|
+
// the hub falls back to its `'email'` default and `validateOutboundRecipient`
|
|
68
|
+
// rejects every real recipient with "Recipient must be a valid email address"
|
|
69
|
+
// — which is #4976 still broken for the provider it was filed against, even
|
|
70
|
+
// though #5261 built the mechanism to fix it. The hub then applies transport
|
|
71
|
+
// safety only (allowlist + length), and this adapter owns the format and must
|
|
72
|
+
// keep treating the value as untrusted.
|
|
73
|
+
recipientFormat: 'provider-native',
|
|
74
|
+
|
|
75
|
+
// Core
|
|
76
|
+
threading: false,
|
|
77
|
+
richText: true,
|
|
78
|
+
fileSharing: false,
|
|
79
|
+
readReceipts: false,
|
|
80
|
+
deliveryReceipts: false,
|
|
81
|
+
typingIndicators: false,
|
|
82
|
+
|
|
83
|
+
// Extended
|
|
84
|
+
reactions: true,
|
|
85
|
+
multiReactionPerUser: false,
|
|
86
|
+
editMessage: true,
|
|
87
|
+
deleteMessage: true,
|
|
88
|
+
presence: false,
|
|
89
|
+
richBlocks: false,
|
|
90
|
+
interactiveComponents: true,
|
|
91
|
+
inlineImages: false,
|
|
92
|
+
conversationHistory: true,
|
|
93
|
+
contactCards: false,
|
|
94
|
+
locationSharing: false,
|
|
95
|
+
voiceNotes: false,
|
|
96
|
+
stickers: false,
|
|
97
|
+
|
|
98
|
+
// Content format support
|
|
99
|
+
supportedBodyFormats: ['text', 'markdown'],
|
|
100
|
+
maxBodyLength: DISCORD_MAX_BODY_LENGTH,
|
|
101
|
+
|
|
102
|
+
// The gateway worker is the real-time source; the hub must not schedule polling.
|
|
103
|
+
realtimePush: true,
|
|
104
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { NextResponse } from 'next/server'
|
|
4
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
6
|
+
import { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
|
|
7
|
+
import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
|
|
8
|
+
import {
|
|
9
|
+
assertCanAccessChannel,
|
|
10
|
+
assertCanManageChannel,
|
|
11
|
+
channelOrgScopeWhereFromFilter,
|
|
12
|
+
ChannelAccessDeniedError,
|
|
13
|
+
} from '@open-mercato/core/modules/communication_channels/lib/access-control'
|
|
14
|
+
|
|
15
|
+
type RouteAuth = {
|
|
16
|
+
sub?: string | null
|
|
17
|
+
tenantId?: string | null
|
|
18
|
+
orgId?: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type RbacServiceLike = {
|
|
22
|
+
loadAcl: (
|
|
23
|
+
userId: string,
|
|
24
|
+
scope: { tenantId: string | null; organizationId: string | null },
|
|
25
|
+
) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type DiscordChannelAccess = {
|
|
29
|
+
channel: CommunicationChannel
|
|
30
|
+
/** Organizations the caller may see, for the command's own scoped re-lookup. */
|
|
31
|
+
organizationIds: string[] | null
|
|
32
|
+
/** Organization the decryption scope is anchored to. */
|
|
33
|
+
rbacOrganizationId: string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load a Discord channel for a provider-owned route, scoped and authorized the
|
|
38
|
+
* same way the hub's own channel-scoped routes do.
|
|
39
|
+
*
|
|
40
|
+
* Reusing the hub's helpers rather than filtering on `auth.orgId` matters twice
|
|
41
|
+
* over. A Discord bot channel is usually tenant-scoped (`organization_id IS
|
|
42
|
+
* NULL`), so an equality filter on the caller's selected organization would hide
|
|
43
|
+
* exactly the channels this feature configures (#5012 fixed the same bug on the
|
|
44
|
+
* hub's detail route). And a per-user channel belongs to its owner: the shared
|
|
45
|
+
* access guards are what stop one operator from arming an AI on another's
|
|
46
|
+
* mailbox, which no feature grant should be able to override.
|
|
47
|
+
*
|
|
48
|
+
* Returns `{ response }` with a masked 404 when the channel is absent or the
|
|
49
|
+
* caller may not touch it — existence masking, consistent with the hub.
|
|
50
|
+
*/
|
|
51
|
+
export async function loadDiscordChannelForRequest(params: {
|
|
52
|
+
container: AwilixContainer
|
|
53
|
+
req: Request
|
|
54
|
+
auth: RouteAuth
|
|
55
|
+
channelId: string
|
|
56
|
+
mode: 'read' | 'manage'
|
|
57
|
+
}): Promise<DiscordChannelAccess | { response: Response }> {
|
|
58
|
+
const { container, req, auth, channelId, mode } = params
|
|
59
|
+
const notFound = { response: NextResponse.json({ error: 'Channel not found' }, { status: 404 }) }
|
|
60
|
+
|
|
61
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
62
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth: auth as never, request: req })
|
|
63
|
+
const orgFilter = resolveOrganizationScopeFilter(scope, auth as never)
|
|
64
|
+
|
|
65
|
+
const channel = await findOneWithDecryption(
|
|
66
|
+
em,
|
|
67
|
+
CommunicationChannel,
|
|
68
|
+
{
|
|
69
|
+
id: channelId,
|
|
70
|
+
tenantId: auth.tenantId as string,
|
|
71
|
+
providerKey: 'discord',
|
|
72
|
+
...channelOrgScopeWhereFromFilter(orgFilter),
|
|
73
|
+
deletedAt: null,
|
|
74
|
+
},
|
|
75
|
+
undefined,
|
|
76
|
+
{ tenantId: auth.tenantId as string, organizationId: orgFilter.rbacOrganizationId },
|
|
77
|
+
)
|
|
78
|
+
if (!channel) return notFound
|
|
79
|
+
|
|
80
|
+
let userFeatures: string[] = []
|
|
81
|
+
try {
|
|
82
|
+
const rbac = container.resolve('rbacService') as RbacServiceLike
|
|
83
|
+
const acl = await rbac.loadAcl(auth.sub as string, {
|
|
84
|
+
tenantId: auth.tenantId as string,
|
|
85
|
+
organizationId: orgFilter.rbacOrganizationId,
|
|
86
|
+
})
|
|
87
|
+
userFeatures = acl.features
|
|
88
|
+
} catch {
|
|
89
|
+
// A failed ACL load leaves `userFeatures` empty, which only makes the guard
|
|
90
|
+
// below stricter — it can deny, never grant.
|
|
91
|
+
userFeatures = []
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
if (mode === 'manage') {
|
|
96
|
+
assertCanManageChannel(channel, auth.sub ?? null, userFeatures, 'communication_channels.manage')
|
|
97
|
+
} else {
|
|
98
|
+
assertCanAccessChannel(channel, auth.sub ?? null, userFeatures)
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err instanceof ChannelAccessDeniedError) return notFound
|
|
102
|
+
throw err
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
channel,
|
|
107
|
+
organizationIds: orgFilter.organizationIds ?? null,
|
|
108
|
+
rbacOrganizationId: orgFilter.rbacOrganizationId,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
|
+
import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
|
|
4
|
+
import { discordChannelStateSchema, type DiscordChannelState } from './credentials'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tenant/organization scope every Discord channel-state write MUST carry.
|
|
8
|
+
* The gateway worker persists resume state from a WebSocket callback that fires
|
|
9
|
+
* long after the job handler returned, so the scope is captured up front from
|
|
10
|
+
* the channel row the job loaded and replayed on every write.
|
|
11
|
+
*/
|
|
12
|
+
export type DiscordChannelScope = {
|
|
13
|
+
tenantId: string
|
|
14
|
+
organizationId: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The gateway-owned subset of `channelState` this store is allowed to write. */
|
|
18
|
+
export type DiscordChannelStatePatch = {
|
|
19
|
+
sessionId?: string
|
|
20
|
+
sequence?: number | null
|
|
21
|
+
resumeGatewayUrl?: string
|
|
22
|
+
botUserId?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PersistDiscordChannelStateResult = 'written' | 'skipped' | 'not_found'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Merge a gateway resume-state patch onto the state currently stored on the
|
|
29
|
+
* channel row, or return `null` when the patch must not be written.
|
|
30
|
+
*
|
|
31
|
+
* Two rules make this safe against the socket callbacks racing each other:
|
|
32
|
+
*
|
|
33
|
+
* - Only the gateway-owned keys are touched; every other key (`aiAutoReplyEnabled`,
|
|
34
|
+
* `aiAgentId`, operator-set fields) is carried forward from the row that was
|
|
35
|
+
* just read, so a concurrent UI edit is never clobbered wholesale.
|
|
36
|
+
* - A patch that would rewind the sequence of the session already stored is
|
|
37
|
+
* dropped. A late `READY` callback from a superseded socket must not overwrite
|
|
38
|
+
* fresher resume state with a stale cursor.
|
|
39
|
+
*/
|
|
40
|
+
export function mergeDiscordChannelState(
|
|
41
|
+
currentRaw: unknown,
|
|
42
|
+
patch: DiscordChannelStatePatch,
|
|
43
|
+
now: Date,
|
|
44
|
+
): DiscordChannelState | null {
|
|
45
|
+
const current = discordChannelStateSchema.parse(currentRaw ?? {})
|
|
46
|
+
const sameSession = Boolean(patch.sessionId) && patch.sessionId === current.sessionId
|
|
47
|
+
const rewindsSequence =
|
|
48
|
+
sameSession &&
|
|
49
|
+
typeof patch.sequence === 'number' &&
|
|
50
|
+
typeof current.sequence === 'number' &&
|
|
51
|
+
patch.sequence < current.sequence
|
|
52
|
+
if (rewindsSequence) return null
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
...current,
|
|
56
|
+
...patch,
|
|
57
|
+
lastConnectedAt: now.toISOString(),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Scoped read-modify-write of a Discord channel's gateway resume state.
|
|
63
|
+
*
|
|
64
|
+
* The lookup filters by `tenant_id` / `organization_id` (never by id alone) so a
|
|
65
|
+
* socket callback can only ever touch the row its own channel scope owns, and it
|
|
66
|
+
* runs on its own `EntityManager` fork so the flush never rides on the request
|
|
67
|
+
* context the worker job was started from.
|
|
68
|
+
*/
|
|
69
|
+
export async function persistDiscordChannelState(params: {
|
|
70
|
+
em: EntityManager
|
|
71
|
+
channelId: string
|
|
72
|
+
scope: DiscordChannelScope
|
|
73
|
+
patch: DiscordChannelStatePatch
|
|
74
|
+
now?: Date
|
|
75
|
+
}): Promise<PersistDiscordChannelStateResult> {
|
|
76
|
+
const fork = params.em.fork()
|
|
77
|
+
const channel = await findOneWithDecryption(
|
|
78
|
+
fork,
|
|
79
|
+
CommunicationChannel,
|
|
80
|
+
{
|
|
81
|
+
id: params.channelId,
|
|
82
|
+
tenantId: params.scope.tenantId,
|
|
83
|
+
organizationId: params.scope.organizationId,
|
|
84
|
+
deletedAt: null,
|
|
85
|
+
},
|
|
86
|
+
undefined,
|
|
87
|
+
{
|
|
88
|
+
tenantId: params.scope.tenantId,
|
|
89
|
+
organizationId: params.scope.organizationId ?? params.scope.tenantId,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
if (!channel) return 'not_found'
|
|
93
|
+
|
|
94
|
+
const merged = mergeDiscordChannelState(channel.channelState, params.patch, params.now ?? new Date())
|
|
95
|
+
if (!merged) return 'skipped'
|
|
96
|
+
|
|
97
|
+
channel.channelState = merged
|
|
98
|
+
await fork.flush()
|
|
99
|
+
return 'written'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type RecordAutoReplyOutcomeResult = 'written' | 'unchanged' | 'not_found'
|
|
103
|
+
|
|
104
|
+
/** Truncated so a provider stack trace cannot grow the JSONB column without bound. */
|
|
105
|
+
const AUTO_REPLY_ERROR_MAX_LENGTH = 500
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Record — or clear — why the last AI auto-reply attempt on this channel produced
|
|
109
|
+
* nothing.
|
|
110
|
+
*
|
|
111
|
+
* The subscriber degrades every failure to a no-op on purpose: a broken model, a
|
|
112
|
+
* denied policy check or a malformed object must never turn into a send. Being
|
|
113
|
+
* *silent* about it is the part that hurts, because the operator's surface still
|
|
114
|
+
* reads "Auto-reply on" while the channel answers nothing, and a `logger.warn` in
|
|
115
|
+
* a background subscriber is not somewhere anyone looks. A save-time check cannot
|
|
116
|
+
* close this on its own either — the settings route validates the agent against
|
|
117
|
+
* the auto-reply principal, but a role edited afterwards makes that verdict stale.
|
|
118
|
+
*
|
|
119
|
+
* `failure: null` clears the marker, and the caller is expected to skip the write
|
|
120
|
+
* when there is nothing to clear — a successful reply on a healthy channel must
|
|
121
|
+
* not cost a row update per inbound message.
|
|
122
|
+
*
|
|
123
|
+
* The channel row's own `lastError` is deliberately left alone: the hub owns it
|
|
124
|
+
* for delivery and polling failures, clears it on a successful poll, and keys the
|
|
125
|
+
* reauth banner off `status`. Borrowing it here would let an AI failure mask a
|
|
126
|
+
* delivery failure and let a successful poll erase an AI one.
|
|
127
|
+
*/
|
|
128
|
+
export async function recordDiscordAutoReplyOutcome(params: {
|
|
129
|
+
em: EntityManager
|
|
130
|
+
channelId: string
|
|
131
|
+
scope: DiscordChannelScope
|
|
132
|
+
failure: string | null
|
|
133
|
+
now?: Date
|
|
134
|
+
}): Promise<RecordAutoReplyOutcomeResult> {
|
|
135
|
+
const fork = params.em.fork()
|
|
136
|
+
const channel = await findOneWithDecryption(
|
|
137
|
+
fork,
|
|
138
|
+
CommunicationChannel,
|
|
139
|
+
{
|
|
140
|
+
id: params.channelId,
|
|
141
|
+
tenantId: params.scope.tenantId,
|
|
142
|
+
organizationId: params.scope.organizationId,
|
|
143
|
+
deletedAt: null,
|
|
144
|
+
},
|
|
145
|
+
undefined,
|
|
146
|
+
{
|
|
147
|
+
tenantId: params.scope.tenantId,
|
|
148
|
+
organizationId: params.scope.organizationId ?? params.scope.tenantId,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
if (!channel) return 'not_found'
|
|
152
|
+
|
|
153
|
+
const current = discordChannelStateSchema.parse(channel.channelState ?? {})
|
|
154
|
+
const failure = params.failure?.slice(0, AUTO_REPLY_ERROR_MAX_LENGTH) ?? null
|
|
155
|
+
if (failure === null && current.aiAutoReplyLastError === undefined) return 'unchanged'
|
|
156
|
+
if (failure !== null && current.aiAutoReplyLastError === failure) return 'unchanged'
|
|
157
|
+
|
|
158
|
+
const next: DiscordChannelState = { ...current }
|
|
159
|
+
if (failure === null) {
|
|
160
|
+
delete next.aiAutoReplyLastError
|
|
161
|
+
delete next.aiAutoReplyLastErrorAt
|
|
162
|
+
} else {
|
|
163
|
+
next.aiAutoReplyLastError = failure
|
|
164
|
+
next.aiAutoReplyLastErrorAt = (params.now ?? new Date()).toISOString()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
channel.channelState = next
|
|
168
|
+
await fork.flush()
|
|
169
|
+
return 'written'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export type QuarantineDiscordChannelResult = 'quarantined' | 'not_found'
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Park a channel Discord has fatally rejected so the reconciler stops re-opening it.
|
|
176
|
+
*
|
|
177
|
+
* A `4004` (invalid token) or `4014` (disallowed intents) close is not
|
|
178
|
+
* recoverable by retrying: the token or the Developer Portal toggle has to
|
|
179
|
+
* change first. Without a persisted status the refresh job sees "no live
|
|
180
|
+
* session" on the next tick and IDENTIFYs again — at the CLI's default 60s
|
|
181
|
+
* refresh that is ~1440 session starts per day against Discord's ~1000/day
|
|
182
|
+
* per-bot budget, while the row and the admin UI still read `connected`.
|
|
183
|
+
*
|
|
184
|
+
* Writing `requires_reauth` mirrors what the hub already does for a channel
|
|
185
|
+
* whose credentials could not be persisted (`connect-channel.ts`), so the
|
|
186
|
+
* existing reauth banner, the mutation guard that keys on `status`, and the
|
|
187
|
+
* operator's reconnect flow all engage without any hub change.
|
|
188
|
+
*/
|
|
189
|
+
export async function quarantineDiscordChannel(params: {
|
|
190
|
+
em: EntityManager
|
|
191
|
+
channelId: string
|
|
192
|
+
scope: DiscordChannelScope
|
|
193
|
+
reason: string
|
|
194
|
+
}): Promise<QuarantineDiscordChannelResult> {
|
|
195
|
+
const fork = params.em.fork()
|
|
196
|
+
const channel = await findOneWithDecryption(
|
|
197
|
+
fork,
|
|
198
|
+
CommunicationChannel,
|
|
199
|
+
{
|
|
200
|
+
id: params.channelId,
|
|
201
|
+
tenantId: params.scope.tenantId,
|
|
202
|
+
organizationId: params.scope.organizationId,
|
|
203
|
+
deletedAt: null,
|
|
204
|
+
},
|
|
205
|
+
undefined,
|
|
206
|
+
{
|
|
207
|
+
tenantId: params.scope.tenantId,
|
|
208
|
+
organizationId: params.scope.organizationId ?? params.scope.tenantId,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
if (!channel) return 'not_found'
|
|
212
|
+
|
|
213
|
+
channel.status = 'requires_reauth'
|
|
214
|
+
channel.lastError = params.reason
|
|
215
|
+
await fork.flush()
|
|
216
|
+
return 'quarantined'
|
|
217
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelNativeContent,
|
|
3
|
+
ConvertOutboundInput,
|
|
4
|
+
} from '@open-mercato/core/modules/communication_channels/lib/adapter'
|
|
5
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
|
+
import { DISCORD_MAX_BODY_LENGTH } from './capabilities'
|
|
7
|
+
|
|
8
|
+
const logger = createLogger('channel_discord').child({ component: 'convert-outbound' })
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Very small HTML → markdown down-converter for the common inline tags the hub's
|
|
12
|
+
* `html` body format produces. Discord content is markdown-native, so we map the
|
|
13
|
+
* handful of tags that have a markdown equivalent and strip the rest rather than
|
|
14
|
+
* pulling in a full HTML parser dependency.
|
|
15
|
+
*/
|
|
16
|
+
function htmlToMarkdown(html: string): string {
|
|
17
|
+
return html
|
|
18
|
+
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
|
|
19
|
+
.replace(/<\s*\/\s*p\s*>/gi, '\n\n')
|
|
20
|
+
.replace(/<\s*p[^>]*>/gi, '')
|
|
21
|
+
.replace(/<\s*(strong|b)\s*>(.*?)<\s*\/\s*\1\s*>/gis, '**$2**')
|
|
22
|
+
.replace(/<\s*(em|i)\s*>(.*?)<\s*\/\s*\1\s*>/gis, '*$2*')
|
|
23
|
+
.replace(/<\s*code\s*>(.*?)<\s*\/\s*code\s*>/gis, '`$1`')
|
|
24
|
+
.replace(/<\s*a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\s*\/\s*a\s*>/gis, '[$2]($1)')
|
|
25
|
+
.replace(/<[^>]+>/g, '')
|
|
26
|
+
.replace(/ /gi, ' ')
|
|
27
|
+
.replace(/&/gi, '&')
|
|
28
|
+
.replace(/</gi, '<')
|
|
29
|
+
.replace(/>/gi, '>')
|
|
30
|
+
.replace(/"/gi, '"')
|
|
31
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
32
|
+
.trim()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Convert the hub's normalized outbound body into Discord native content.
|
|
37
|
+
*
|
|
38
|
+
* - `markdown` / `text` pass through unchanged (Discord is markdown-native).
|
|
39
|
+
* - `html` is down-converted to markdown.
|
|
40
|
+
* - Content is clamped to Discord's 2000-char hard limit (a longer body is
|
|
41
|
+
* truncated with an ellipsis marker rather than rejected by the API).
|
|
42
|
+
* - `allowed_mentions` defaults to `{ parse: [] }` so an AI/automated reply can
|
|
43
|
+
* never accidentally @-ping everyone; callers can widen it via `channelMetadata`.
|
|
44
|
+
* - Attachments are NOT uploaded: `discord-rest` has no multipart upload, so the
|
|
45
|
+
* capability profile declares `fileSharing: false` and the hub should never
|
|
46
|
+
* route an attachment here. If one arrives anyway we log it rather than drop
|
|
47
|
+
* it silently, and report the count in `metadata.droppedAttachmentCount` so the
|
|
48
|
+
* caller can surface the gap.
|
|
49
|
+
*/
|
|
50
|
+
export async function convertOutboundForDiscord(input: ConvertOutboundInput): Promise<ChannelNativeContent> {
|
|
51
|
+
const droppedAttachmentCount = input.attachments?.length ?? 0
|
|
52
|
+
if (droppedAttachmentCount > 0) {
|
|
53
|
+
logger.warn('discord outbound attachments are not supported — sending text only', {
|
|
54
|
+
droppedAttachmentCount,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const raw = input.body ?? ''
|
|
59
|
+
const markdown = input.bodyFormat === 'html' ? htmlToMarkdown(raw) : raw
|
|
60
|
+
|
|
61
|
+
const TRUNCATE_MARKER = '…'
|
|
62
|
+
const content =
|
|
63
|
+
markdown.length > DISCORD_MAX_BODY_LENGTH
|
|
64
|
+
? markdown.slice(0, DISCORD_MAX_BODY_LENGTH - TRUNCATE_MARKER.length) + TRUNCATE_MARKER
|
|
65
|
+
: markdown
|
|
66
|
+
|
|
67
|
+
const allowedMentions =
|
|
68
|
+
(input.channelMetadata?.allowedMentions as Record<string, unknown> | undefined) ?? { parse: [] }
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
content: {
|
|
72
|
+
text: content,
|
|
73
|
+
bodyFormat: 'markdown',
|
|
74
|
+
},
|
|
75
|
+
metadata: {
|
|
76
|
+
allowedMentions,
|
|
77
|
+
droppedAttachmentCount,
|
|
78
|
+
// Reply threading, dormant: no hub producer writes `replyToExternalId`
|
|
79
|
+
// into outbound metadata today, which is why `capabilities.threading` is
|
|
80
|
+
// declared `false`. Kept so that flag flips back with the hub-side
|
|
81
|
+
// producer and nothing else.
|
|
82
|
+
messageReferenceId:
|
|
83
|
+
typeof input.channelMetadata?.replyToExternalId === 'string'
|
|
84
|
+
? (input.channelMetadata.replyToExternalId as string)
|
|
85
|
+
: undefined,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Discord bot channel credentials (SPEC 2026-06-19 § Data models).
|
|
5
|
+
*
|
|
6
|
+
* The hub persists this blob inside `IntegrationCredentials.credentials`
|
|
7
|
+
* (encrypted at rest, scope `channel_discord`). Never log any value — the bot
|
|
8
|
+
* token grants full control of the bot user.
|
|
9
|
+
*
|
|
10
|
+
* `.passthrough()` (not `.strict()`) so the connect-credential-channel command
|
|
11
|
+
* can stash bookkeeping fields (e.g. `userId`) alongside the entered credentials,
|
|
12
|
+
* mirroring the IMAP provider.
|
|
13
|
+
*/
|
|
14
|
+
export const discordCredentialsSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
// "Bot <token>" — used as `Authorization: Bot <token>` on every REST call and
|
|
17
|
+
// for the gateway Identify handshake. Never logged.
|
|
18
|
+
botToken: z.string().min(1, 'Bot token required'),
|
|
19
|
+
// Application (client) id — needed to register slash commands + build invites.
|
|
20
|
+
applicationId: z.string().min(1, 'Application ID required'),
|
|
21
|
+
// Ed25519 public key (hex) from the application's General Information tab —
|
|
22
|
+
// verifies signed interaction requests. Never used as a secret; safe to store.
|
|
23
|
+
publicKey: z
|
|
24
|
+
.string()
|
|
25
|
+
.min(1, 'Public key required')
|
|
26
|
+
.regex(/^[0-9a-fA-F]+$/, 'Public key must be hex')
|
|
27
|
+
.refine((value) => value.length === 64, 'Public key must be a 32-byte (64 hex char) Ed25519 key'),
|
|
28
|
+
// Scope the bot to one guild (recommended). Optional.
|
|
29
|
+
guildId: z.string().optional(),
|
|
30
|
+
// Default outbound text channel id (used by the test-send smoke test).
|
|
31
|
+
defaultChannelId: z.string().optional(),
|
|
32
|
+
})
|
|
33
|
+
.passthrough()
|
|
34
|
+
|
|
35
|
+
export type DiscordCredentials = z.infer<typeof discordCredentialsSchema>
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Gateway resume state persisted on `CommunicationChannel.channelState` (JSONB,
|
|
39
|
+
* additive) so the worker can `RESUME` instead of re-`IDENTIFY` after a
|
|
40
|
+
* disconnect. Discord requires the stored `resumeGatewayUrl` + `sessionId` +
|
|
41
|
+
* last `sequence` to resume a session.
|
|
42
|
+
*/
|
|
43
|
+
export const discordChannelStateSchema = z
|
|
44
|
+
.object({
|
|
45
|
+
sessionId: z.string().optional(),
|
|
46
|
+
sequence: z.number().nullable().optional(),
|
|
47
|
+
resumeGatewayUrl: z.string().optional(),
|
|
48
|
+
// Bot's own user id — cached after the READY event so the worker can drop
|
|
49
|
+
// events it authored (feedback-loop guard) without an extra REST call.
|
|
50
|
+
botUserId: z.string().optional(),
|
|
51
|
+
lastConnectedAt: z.string().optional(),
|
|
52
|
+
// Per-channel AI auto-reply toggle (default OFF). When truthy, the AI
|
|
53
|
+
// auto-reply subscriber may answer "easy" inbound messages and proposes a
|
|
54
|
+
// reply for everything else. Written by the channel's AI auto-reply settings
|
|
55
|
+
// form via `PUT /api/channel_discord/channels/{id}/ai-auto-reply`.
|
|
56
|
+
aiAutoReplyEnabled: z.boolean().optional(),
|
|
57
|
+
// Object-mode agent id invoked when auto-reply is enabled. Defaults to the
|
|
58
|
+
// provider's own `channel_discord.auto_reply` agent; a tenant may point it at
|
|
59
|
+
// any object-mode agent whose `requiredFeatures` its channel-bot user holds.
|
|
60
|
+
aiAgentId: z.string().optional(),
|
|
61
|
+
// Why the last auto-reply attempt produced nothing, cleared by the next
|
|
62
|
+
// attempt that gets somewhere. The settings route validates a channel's agent
|
|
63
|
+
// against the auto-reply principal before arming it, but a role edit after the
|
|
64
|
+
// fact can still make the runtime start denying the call — and the subscriber
|
|
65
|
+
// degrades to a no-op by design, because a broken model must never become a
|
|
66
|
+
// send. Without a persisted marker that leaves an "Auto-reply on" channel
|
|
67
|
+
// answering nothing with nothing for an operator to look at. Written only by
|
|
68
|
+
// `lib/channel-state-store.ts`, never by the subscriber directly.
|
|
69
|
+
aiAutoReplyLastError: z.string().optional(),
|
|
70
|
+
// When the *current* failure was first observed. A channel that keeps failing
|
|
71
|
+
// for the same reason does not rewrite the row on every inbound message, so
|
|
72
|
+
// this is a first-seen stamp rather than a last-attempt one — the alternative
|
|
73
|
+
// is a row update per message on exactly the channels already misbehaving.
|
|
74
|
+
aiAutoReplyLastErrorAt: z.string().optional(),
|
|
75
|
+
})
|
|
76
|
+
.partial()
|
|
77
|
+
.passthrough()
|
|
78
|
+
|
|
79
|
+
export type DiscordChannelState = z.infer<typeof discordChannelStateSchema>
|
|
80
|
+
|
|
81
|
+
export function parseDiscordCredentialsOrThrow(value: unknown): DiscordCredentials {
|
|
82
|
+
const parsed = discordCredentialsSchema.safeParse(value)
|
|
83
|
+
if (!parsed.success) {
|
|
84
|
+
const first = parsed.error.issues[0]
|
|
85
|
+
throw new Error(`[internal] Invalid Discord credentials: ${first?.message ?? 'unknown validation error'}`)
|
|
86
|
+
}
|
|
87
|
+
return parsed.data
|
|
88
|
+
}
|