@open-mercato/channel-discord 0.8.1-develop.7221.1.ece7ca874e → 0.8.1-develop.7225.1.73190a4a95
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/dist/modules/channel_discord/lib/adapter.js +6 -2
- package/dist/modules/channel_discord/lib/adapter.js.map +2 -2
- package/dist/modules/channel_discord/lib/channel-identity.js +5 -1
- package/dist/modules/channel_discord/lib/channel-identity.js.map +2 -2
- package/dist/modules/channel_discord/workers/discord-gateway.js.map +1 -1
- package/package.json +8 -8
- package/src/modules/channel_discord/lib/__tests__/adapter.test.ts +14 -0
- package/src/modules/channel_discord/lib/adapter.ts +6 -2
- package/src/modules/channel_discord/lib/channel-identity.ts +9 -0
- package/src/modules/channel_discord/workers/discord-gateway.ts +6 -6
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { discordCapabilities } from "./capabilities.js";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DISCORD_CHANNEL_TYPE,
|
|
4
|
+
DISCORD_PROVIDER_KEY,
|
|
5
|
+
discordChannelExternalIdentifier
|
|
6
|
+
} from "./channel-identity.js";
|
|
3
7
|
import { parseDiscordCredentialsOrThrow, discordCredentialsSchema } from "./credentials.js";
|
|
4
8
|
import {
|
|
5
9
|
DiscordApiError,
|
|
@@ -197,7 +201,7 @@ class DiscordChannelAdapter {
|
|
|
197
201
|
}
|
|
198
202
|
try {
|
|
199
203
|
await getDiscordRestClient().getCurrentUser({ botToken: parsed.data.botToken });
|
|
200
|
-
return { ok: true };
|
|
204
|
+
return { ok: true, externalIdentifier: discordChannelExternalIdentifier(parsed.data.applicationId) };
|
|
201
205
|
} catch (error) {
|
|
202
206
|
const status = error instanceof DiscordApiError ? error.status : 0;
|
|
203
207
|
return {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_discord/lib/adapter.ts"],
|
|
4
|
-
"sourcesContent": ["import type {\n ChannelAdapter,\n ChannelNativeContent,\n ContactHint,\n ConvertOutboundInput,\n DeleteChannelMessageInput,\n EditChannelMessageInput,\n FetchHistoryInput,\n GetMessageStatusInput,\n HistoryPage,\n InboundMessage,\n InboundReactionEvent,\n MessageStatus,\n NormalizedInboundMessage,\n RemoveReactionInput,\n ResolveContactInput,\n SendMessageInput,\n SendMessageResult,\n SendReactionInput,\n ValidateCredentialsInput,\n ValidateCredentialsResult,\n VerifyWebhookInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { discordCapabilities } from './capabilities'\nimport { DISCORD_CHANNEL_TYPE, DISCORD_PROVIDER_KEY } from './channel-identity'\nimport { parseDiscordCredentialsOrThrow, discordCredentialsSchema } from './credentials'\nimport {\n DiscordApiError,\n getDiscordRestClient,\n type DiscordMessageObject,\n} from './discord-rest'\nimport { convertOutboundForDiscord } from './convert-outbound'\nimport {\n DISCORD_CONVERSATION_PREFIX,\n normalizeInboundDiscordMessage,\n} from './normalize-inbound'\nimport {\n parseInteractionBody,\n verifyDiscordSignature,\n} from './interactions-verify'\n\n/**\n * Discord `ChannelAdapter` (SPEC 2026-06-19).\n *\n * Transport split:\n * - Outbound: Discord REST API (`sendMessage`, edit/delete/reactions).\n * - Inbound messages: a provider-owned **Gateway worker** pushes\n * `MESSAGE_CREATE` events into the hub's ingest command \u2014 NOT this\n * `verifyWebhook` hook (Discord does not POST normal messages to a webhook).\n * - Slash commands / buttons: a signed **Interactions** endpoint\n * (`api/interactions` \u2192 `/api/channel_discord/interactions`) with Ed25519 verification.\n *\n * `verifyWebhook` security contract (fail-closed): the shared\n * `api/post/webhook/[provider]` route treats a non-throwing return as \"verified\".\n * For a Discord interaction body we verify the Ed25519 signature and THROW on any\n * failure; for a non-interaction body (a normal message never arrives here) we\n * return `eventType: 'other'` so the generic route acks without tenant-scoped\n * work. Live PING\u2192PONG handshakes are served by the dedicated interactions route,\n * which can answer synchronously (the generic route only 202-acks).\n */\nclass DiscordChannelAdapter implements ChannelAdapter {\n readonly providerKey = DISCORD_PROVIDER_KEY\n readonly channelType = DISCORD_CHANNEL_TYPE\n readonly capabilities = discordCapabilities\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n\n let native: ChannelNativeContent\n try {\n native = await convertOutboundForDiscord({\n body: input.content.text ?? input.content.html ?? '',\n bodyFormat: input.content.bodyFormat ?? (input.content.html ? 'html' : 'text'),\n attachments: input.content.attachments,\n channelMetadata: input.metadata,\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Outbound conversion failed'\n return { externalMessageId: '', status: 'failed', error: message }\n }\n\n const targetChannelId = resolveTargetChannelId(input, credentials.defaultChannelId)\n if (!targetChannelId) {\n // Operator-facing, not `[internal]`: since #4976 made the hub's test-send\n // recipient optional, this is the response an operator gets for the first\n // thing they are told to try \u2014 a smoke test on a connection whose\n // `Default channel ID` was left blank. It surfaces verbatim as\n // `providerError`, so it names the two ways out rather than the internals.\n return {\n externalMessageId: '',\n status: 'failed',\n error: 'No Discord channel to post to \u2014 set \"Default channel ID\" on the connection, or pass a channel id as the recipient.',\n }\n }\n\n const meta = (native.metadata ?? {}) as Record<string, unknown>\n try {\n const message = await getDiscordRestClient().createMessage(\n { botToken: credentials.botToken },\n {\n channelId: targetChannelId,\n content: native.content.text ?? '',\n messageReferenceId: typeof meta.messageReferenceId === 'string' ? meta.messageReferenceId : undefined,\n allowedMentions: (meta.allowedMentions as Record<string, unknown> | undefined) ?? { parse: [] },\n },\n )\n return {\n externalMessageId: message.id,\n conversationId: `${DISCORD_CONVERSATION_PREFIX}${message.channel_id}`,\n status: 'sent',\n metadata: { discordChannelId: message.channel_id, discordMessageId: message.id },\n }\n } catch (error) {\n if (error instanceof DiscordApiError && error.status === 401) {\n // Protocol sentinel the hub keys on (error-classification.isReauthError).\n return { externalMessageId: '', status: 'failed', error: 'requires_reauth' }\n }\n const message = error instanceof Error ? error.message : 'Discord send failed'\n return { externalMessageId: '', status: 'failed', error: message }\n }\n }\n\n async verifyWebhook(input: VerifyWebhookInput): Promise<InboundMessage> {\n const rawBody = typeof input.rawBody === 'string' ? input.rawBody : input.rawBody.toString('utf-8')\n const interaction = parseInteractionBody(rawBody)\n if (!interaction) {\n // Not an interaction payload \u2014 Discord messages arrive via the gateway\n // worker, so ack (202) without tenant-scoped work.\n return { raw: {}, eventType: 'other', metadata: { reason: 'discord-uses-gateway-for-messages' } }\n }\n\n const parsed = discordCredentialsSchema.safeParse(input.credentials)\n const publicKeyHex = parsed.success ? parsed.data.publicKey : ''\n const headers = normalizeHeaders(input.headers)\n const ok = verifyDiscordSignature({\n publicKeyHex,\n signatureHex: headers['x-signature-ed25519'],\n timestamp: headers['x-signature-timestamp'],\n rawBody,\n })\n if (!ok) {\n // FAIL-CLOSED: throwing makes the shared route reject (401) and pins nothing.\n throw new Error('[internal] Discord interaction signature verification failed')\n }\n // Verified interaction. The generic hub route cannot answer the synchronous\n // PING\u2192PONG handshake, so real interactions are served by the dedicated\n // `api/interactions` \u2192 `/api/channel_discord/interactions` route; here we only prove the\n // signature and ack.\n return { raw: { discordInteraction: interaction }, eventType: 'other', metadata: { interactionType: interaction.type } }\n }\n\n async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {\n // Discord exposes no per-message delivery-status API; best-effort placeholder.\n return { status: 'sent' }\n }\n\n async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n return convertOutboundForDiscord(input)\n }\n\n async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {\n const message = pickDiscordMessage(raw)\n return normalizeInboundDiscordMessage(message)\n }\n\n async normalizeInboundReaction(raw: InboundMessage): Promise<InboundReactionEvent> {\n const payload = (raw.raw?.discordReaction ?? raw.raw) as {\n message_id?: string\n channel_id?: string\n emoji?: { name?: string; id?: string | null }\n user_id?: string\n member?: { user?: { username?: string; global_name?: string | null } }\n action?: 'added' | 'removed'\n }\n const emojiName = payload.emoji?.name ?? ''\n const emoji = payload.emoji?.id ? `${emojiName}:${payload.emoji.id}` : emojiName\n return {\n externalMessageId: payload.message_id ?? '',\n externalConversationId: payload.channel_id\n ? `${DISCORD_CONVERSATION_PREFIX}${payload.channel_id}`\n : undefined,\n emoji,\n userIdentifier: payload.user_id ?? 'unknown',\n userDisplayName: payload.member?.user?.global_name ?? payload.member?.user?.username,\n action: payload.action === 'removed' ? 'removed' : 'added',\n raw: payload as Record<string, unknown>,\n }\n }\n\n async sendReaction(input: SendReactionInput): Promise<void> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId\n if (!channelId) throw new Error('[internal] Discord sendReaction requires a channel id')\n await getDiscordRestClient().addReaction(\n { botToken: credentials.botToken },\n channelId,\n input.externalMessageId,\n input.emoji,\n )\n }\n\n async removeReaction(input: RemoveReactionInput): Promise<void> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId\n if (!channelId) throw new Error('[internal] Discord removeReaction requires a channel id')\n await getDiscordRestClient().removeReaction(\n { botToken: credentials.botToken },\n channelId,\n input.externalMessageId,\n input.emoji,\n )\n }\n\n async editMessage(input: EditChannelMessageInput): Promise<void> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId\n if (!channelId) throw new Error('[internal] Discord editMessage requires a channel id')\n const content = input.newContent.text ?? input.newContent.html ?? ''\n await getDiscordRestClient().editMessage(\n { botToken: credentials.botToken },\n channelId,\n input.externalMessageId,\n content,\n )\n }\n\n async deleteMessage(input: DeleteChannelMessageInput): Promise<void> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId\n if (!channelId) throw new Error('[internal] Discord deleteMessage requires a channel id')\n await getDiscordRestClient().deleteMessage(\n { botToken: credentials.botToken },\n channelId,\n input.externalMessageId,\n )\n }\n\n async fetchHistory(input: FetchHistoryInput): Promise<HistoryPage> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId\n if (!channelId) {\n return { messages: [], hasMore: false }\n }\n const limit = Math.min(Math.max(input.limit ?? 50, 1), 100)\n const raw = await getDiscordRestClient().listMessages(\n { botToken: credentials.botToken },\n channelId,\n { before: input.cursor, limit },\n )\n // Discord returns newest-first; keep that order but expose the oldest id as\n // the next `before` cursor for backward pagination.\n const messages = raw.map((message) => normalizeInboundDiscordMessage(message))\n const oldest = raw.length > 0 ? raw[raw.length - 1].id : undefined\n return {\n messages,\n nextCursor: raw.length >= limit ? oldest : undefined,\n hasMore: raw.length >= limit,\n }\n }\n\n async resolveContact(input: ResolveContactInput): Promise<ContactHint | null> {\n const userId = input.senderIdentifier\n if (!userId || userId === 'unknown') return null\n const displayName =\n input.senderDisplayName ||\n (typeof input.channelMetadata?.discordAuthorUsername === 'string'\n ? (input.channelMetadata.discordAuthorUsername as string)\n : undefined)\n return {\n displayName,\n externalProfileUrl: `https://discord.com/users/${encodeURIComponent(userId)}`,\n }\n }\n\n async validateCredentials(input: ValidateCredentialsInput): Promise<ValidateCredentialsResult> {\n const parsed = discordCredentialsSchema.safeParse(input.credentials)\n if (!parsed.success) {\n const errors: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const path = issue.path[0]\n if (typeof path !== 'string') continue\n if (!errors[path]) errors[path] = issue.message\n }\n return { ok: false, errors }\n }\n try {\n await getDiscordRestClient().getCurrentUser({ botToken: parsed.data.botToken })\n return { ok: true }\n } catch (error) {\n const status = error instanceof DiscordApiError ? error.status : 0\n return {\n ok: false,\n errors: {\n botToken:\n status === 401\n ? 'Discord rejected the bot token. Reset it in the Developer Portal and paste the new value.'\n : 'Could not reach Discord to validate the bot token. Check the token and try again.',\n },\n }\n }\n }\n}\n\nfunction normalizeHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string> {\n const out: Record<string, string> = {}\n for (const [key, value] of Object.entries(headers)) {\n if (value === undefined) continue\n out[key.toLowerCase()] = Array.isArray(value) ? (value[0] ?? '') : value\n }\n return out\n}\n\nfunction stripConversationPrefix(conversationId: string | undefined): string | undefined {\n if (!conversationId) return undefined\n return conversationId.startsWith(DISCORD_CONVERSATION_PREFIX)\n ? conversationId.slice(DISCORD_CONVERSATION_PREFIX.length)\n : conversationId\n}\n\n/**\n * Where an outbound message is actually posted, in priority order.\n *\n * `metadata.to` is in this list because of what QA found on #4391: the hub's\n * `test-send` route puts the operator's recipient in `metadata.to`, nothing\n * here read it, and every send therefore went to `defaultChannelId` while the\n * endpoint answered `200 sent`. Validating a recipient and then discarding it\n * is worse than rejecting it \u2014 an operator test-sending to one channel got a\n * success for a message that landed in another.\n *\n * `conversationId` deliberately outranks `metadata.to`: on a reply the thread\n * mapping is authoritative, and a stray `to` must never redirect a reply out of\n * its own conversation. `metadata.to` only decides when there is no\n * conversation to belong to, which is exactly the test-send case.\n *\n * With `capabilities.recipientFormat: 'provider-native'` the hub has already\n * applied its transport-safety checks to this value, but it stays untrusted\n * input: it reaches `/channels/{id}/messages` and a wrong-but-well-formed id\n * simply fails at Discord, which is the honest outcome.\n */\nfunction resolveTargetChannelId(input: SendMessageInput, defaultChannelId: string | undefined): string | undefined {\n const fromMeta = input.metadata?.discordChannelId\n if (typeof fromMeta === 'string' && fromMeta.length > 0) return fromMeta\n const fromConversation = stripConversationPrefix(input.conversationId)\n if (fromConversation && fromConversation.length > 0) return fromConversation\n const fromRecipient = input.metadata?.to\n if (typeof fromRecipient === 'string' && fromRecipient.length > 0) return fromRecipient\n return defaultChannelId\n}\n\nfunction pickDiscordMessage(raw: InboundMessage): DiscordMessageObject {\n const candidate = (raw.raw?.discordMessage ?? raw.raw) as DiscordMessageObject | undefined\n if (!candidate || typeof candidate !== 'object' || typeof candidate.id !== 'string') {\n throw new Error('[internal] Discord normalizeInbound requires `raw.discordMessage` to be a Discord message object')\n }\n return candidate\n}\n\nlet cachedAdapter: DiscordChannelAdapter | null = null\n\nexport function getDiscordChannelAdapter(): DiscordChannelAdapter {\n if (!cachedAdapter) cachedAdapter = new DiscordChannelAdapter()\n return cachedAdapter\n}\n\nexport { DiscordChannelAdapter }\n"],
|
|
5
|
-
"mappings": "AAuBA,SAAS,2BAA2B;AACpC,
|
|
4
|
+
"sourcesContent": ["import type {\n ChannelAdapter,\n ChannelNativeContent,\n ContactHint,\n ConvertOutboundInput,\n DeleteChannelMessageInput,\n EditChannelMessageInput,\n FetchHistoryInput,\n GetMessageStatusInput,\n HistoryPage,\n InboundMessage,\n InboundReactionEvent,\n MessageStatus,\n NormalizedInboundMessage,\n RemoveReactionInput,\n ResolveContactInput,\n SendMessageInput,\n SendMessageResult,\n SendReactionInput,\n ValidateCredentialsInput,\n ValidateCredentialsResult,\n VerifyWebhookInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { discordCapabilities } from './capabilities'\nimport {\n DISCORD_CHANNEL_TYPE,\n DISCORD_PROVIDER_KEY,\n discordChannelExternalIdentifier,\n} from './channel-identity'\nimport { parseDiscordCredentialsOrThrow, discordCredentialsSchema } from './credentials'\nimport {\n DiscordApiError,\n getDiscordRestClient,\n type DiscordMessageObject,\n} from './discord-rest'\nimport { convertOutboundForDiscord } from './convert-outbound'\nimport {\n DISCORD_CONVERSATION_PREFIX,\n normalizeInboundDiscordMessage,\n} from './normalize-inbound'\nimport {\n parseInteractionBody,\n verifyDiscordSignature,\n} from './interactions-verify'\n\n/**\n * Discord `ChannelAdapter` (SPEC 2026-06-19).\n *\n * Transport split:\n * - Outbound: Discord REST API (`sendMessage`, edit/delete/reactions).\n * - Inbound messages: a provider-owned **Gateway worker** pushes\n * `MESSAGE_CREATE` events into the hub's ingest command \u2014 NOT this\n * `verifyWebhook` hook (Discord does not POST normal messages to a webhook).\n * - Slash commands / buttons: a signed **Interactions** endpoint\n * (`api/interactions` \u2192 `/api/channel_discord/interactions`) with Ed25519 verification.\n *\n * `verifyWebhook` security contract (fail-closed): the shared\n * `api/post/webhook/[provider]` route treats a non-throwing return as \"verified\".\n * For a Discord interaction body we verify the Ed25519 signature and THROW on any\n * failure; for a non-interaction body (a normal message never arrives here) we\n * return `eventType: 'other'` so the generic route acks without tenant-scoped\n * work. Live PING\u2192PONG handshakes are served by the dedicated interactions route,\n * which can answer synchronously (the generic route only 202-acks).\n */\nclass DiscordChannelAdapter implements ChannelAdapter {\n readonly providerKey = DISCORD_PROVIDER_KEY\n readonly channelType = DISCORD_CHANNEL_TYPE\n readonly capabilities = discordCapabilities\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n\n let native: ChannelNativeContent\n try {\n native = await convertOutboundForDiscord({\n body: input.content.text ?? input.content.html ?? '',\n bodyFormat: input.content.bodyFormat ?? (input.content.html ? 'html' : 'text'),\n attachments: input.content.attachments,\n channelMetadata: input.metadata,\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Outbound conversion failed'\n return { externalMessageId: '', status: 'failed', error: message }\n }\n\n const targetChannelId = resolveTargetChannelId(input, credentials.defaultChannelId)\n if (!targetChannelId) {\n // Operator-facing, not `[internal]`: since #4976 made the hub's test-send\n // recipient optional, this is the response an operator gets for the first\n // thing they are told to try \u2014 a smoke test on a connection whose\n // `Default channel ID` was left blank. It surfaces verbatim as\n // `providerError`, so it names the two ways out rather than the internals.\n return {\n externalMessageId: '',\n status: 'failed',\n error: 'No Discord channel to post to \u2014 set \"Default channel ID\" on the connection, or pass a channel id as the recipient.',\n }\n }\n\n const meta = (native.metadata ?? {}) as Record<string, unknown>\n try {\n const message = await getDiscordRestClient().createMessage(\n { botToken: credentials.botToken },\n {\n channelId: targetChannelId,\n content: native.content.text ?? '',\n messageReferenceId: typeof meta.messageReferenceId === 'string' ? meta.messageReferenceId : undefined,\n allowedMentions: (meta.allowedMentions as Record<string, unknown> | undefined) ?? { parse: [] },\n },\n )\n return {\n externalMessageId: message.id,\n conversationId: `${DISCORD_CONVERSATION_PREFIX}${message.channel_id}`,\n status: 'sent',\n metadata: { discordChannelId: message.channel_id, discordMessageId: message.id },\n }\n } catch (error) {\n if (error instanceof DiscordApiError && error.status === 401) {\n // Protocol sentinel the hub keys on (error-classification.isReauthError).\n return { externalMessageId: '', status: 'failed', error: 'requires_reauth' }\n }\n const message = error instanceof Error ? error.message : 'Discord send failed'\n return { externalMessageId: '', status: 'failed', error: message }\n }\n }\n\n async verifyWebhook(input: VerifyWebhookInput): Promise<InboundMessage> {\n const rawBody = typeof input.rawBody === 'string' ? input.rawBody : input.rawBody.toString('utf-8')\n const interaction = parseInteractionBody(rawBody)\n if (!interaction) {\n // Not an interaction payload \u2014 Discord messages arrive via the gateway\n // worker, so ack (202) without tenant-scoped work.\n return { raw: {}, eventType: 'other', metadata: { reason: 'discord-uses-gateway-for-messages' } }\n }\n\n const parsed = discordCredentialsSchema.safeParse(input.credentials)\n const publicKeyHex = parsed.success ? parsed.data.publicKey : ''\n const headers = normalizeHeaders(input.headers)\n const ok = verifyDiscordSignature({\n publicKeyHex,\n signatureHex: headers['x-signature-ed25519'],\n timestamp: headers['x-signature-timestamp'],\n rawBody,\n })\n if (!ok) {\n // FAIL-CLOSED: throwing makes the shared route reject (401) and pins nothing.\n throw new Error('[internal] Discord interaction signature verification failed')\n }\n // Verified interaction. The generic hub route cannot answer the synchronous\n // PING\u2192PONG handshake, so real interactions are served by the dedicated\n // `api/interactions` \u2192 `/api/channel_discord/interactions` route; here we only prove the\n // signature and ack.\n return { raw: { discordInteraction: interaction }, eventType: 'other', metadata: { interactionType: interaction.type } }\n }\n\n async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {\n // Discord exposes no per-message delivery-status API; best-effort placeholder.\n return { status: 'sent' }\n }\n\n async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n return convertOutboundForDiscord(input)\n }\n\n async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {\n const message = pickDiscordMessage(raw)\n return normalizeInboundDiscordMessage(message)\n }\n\n async normalizeInboundReaction(raw: InboundMessage): Promise<InboundReactionEvent> {\n const payload = (raw.raw?.discordReaction ?? raw.raw) as {\n message_id?: string\n channel_id?: string\n emoji?: { name?: string; id?: string | null }\n user_id?: string\n member?: { user?: { username?: string; global_name?: string | null } }\n action?: 'added' | 'removed'\n }\n const emojiName = payload.emoji?.name ?? ''\n const emoji = payload.emoji?.id ? `${emojiName}:${payload.emoji.id}` : emojiName\n return {\n externalMessageId: payload.message_id ?? '',\n externalConversationId: payload.channel_id\n ? `${DISCORD_CONVERSATION_PREFIX}${payload.channel_id}`\n : undefined,\n emoji,\n userIdentifier: payload.user_id ?? 'unknown',\n userDisplayName: payload.member?.user?.global_name ?? payload.member?.user?.username,\n action: payload.action === 'removed' ? 'removed' : 'added',\n raw: payload as Record<string, unknown>,\n }\n }\n\n async sendReaction(input: SendReactionInput): Promise<void> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId\n if (!channelId) throw new Error('[internal] Discord sendReaction requires a channel id')\n await getDiscordRestClient().addReaction(\n { botToken: credentials.botToken },\n channelId,\n input.externalMessageId,\n input.emoji,\n )\n }\n\n async removeReaction(input: RemoveReactionInput): Promise<void> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId\n if (!channelId) throw new Error('[internal] Discord removeReaction requires a channel id')\n await getDiscordRestClient().removeReaction(\n { botToken: credentials.botToken },\n channelId,\n input.externalMessageId,\n input.emoji,\n )\n }\n\n async editMessage(input: EditChannelMessageInput): Promise<void> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId\n if (!channelId) throw new Error('[internal] Discord editMessage requires a channel id')\n const content = input.newContent.text ?? input.newContent.html ?? ''\n await getDiscordRestClient().editMessage(\n { botToken: credentials.botToken },\n channelId,\n input.externalMessageId,\n content,\n )\n }\n\n async deleteMessage(input: DeleteChannelMessageInput): Promise<void> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId\n if (!channelId) throw new Error('[internal] Discord deleteMessage requires a channel id')\n await getDiscordRestClient().deleteMessage(\n { botToken: credentials.botToken },\n channelId,\n input.externalMessageId,\n )\n }\n\n async fetchHistory(input: FetchHistoryInput): Promise<HistoryPage> {\n const credentials = parseDiscordCredentialsOrThrow(input.credentials)\n const channelId = stripConversationPrefix(input.conversationId) ?? credentials.defaultChannelId\n if (!channelId) {\n return { messages: [], hasMore: false }\n }\n const limit = Math.min(Math.max(input.limit ?? 50, 1), 100)\n const raw = await getDiscordRestClient().listMessages(\n { botToken: credentials.botToken },\n channelId,\n { before: input.cursor, limit },\n )\n // Discord returns newest-first; keep that order but expose the oldest id as\n // the next `before` cursor for backward pagination.\n const messages = raw.map((message) => normalizeInboundDiscordMessage(message))\n const oldest = raw.length > 0 ? raw[raw.length - 1].id : undefined\n return {\n messages,\n nextCursor: raw.length >= limit ? oldest : undefined,\n hasMore: raw.length >= limit,\n }\n }\n\n async resolveContact(input: ResolveContactInput): Promise<ContactHint | null> {\n const userId = input.senderIdentifier\n if (!userId || userId === 'unknown') return null\n const displayName =\n input.senderDisplayName ||\n (typeof input.channelMetadata?.discordAuthorUsername === 'string'\n ? (input.channelMetadata.discordAuthorUsername as string)\n : undefined)\n return {\n displayName,\n externalProfileUrl: `https://discord.com/users/${encodeURIComponent(userId)}`,\n }\n }\n\n async validateCredentials(input: ValidateCredentialsInput): Promise<ValidateCredentialsResult> {\n const parsed = discordCredentialsSchema.safeParse(input.credentials)\n if (!parsed.success) {\n const errors: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const path = issue.path[0]\n if (typeof path !== 'string') continue\n if (!errors[path]) errors[path] = issue.message\n }\n return { ok: false, errors }\n }\n try {\n await getDiscordRestClient().getCurrentUser({ botToken: parsed.data.botToken })\n return { ok: true, externalIdentifier: discordChannelExternalIdentifier(parsed.data.applicationId) }\n } catch (error) {\n const status = error instanceof DiscordApiError ? error.status : 0\n return {\n ok: false,\n errors: {\n botToken:\n status === 401\n ? 'Discord rejected the bot token. Reset it in the Developer Portal and paste the new value.'\n : 'Could not reach Discord to validate the bot token. Check the token and try again.',\n },\n }\n }\n }\n}\n\nfunction normalizeHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string> {\n const out: Record<string, string> = {}\n for (const [key, value] of Object.entries(headers)) {\n if (value === undefined) continue\n out[key.toLowerCase()] = Array.isArray(value) ? (value[0] ?? '') : value\n }\n return out\n}\n\nfunction stripConversationPrefix(conversationId: string | undefined): string | undefined {\n if (!conversationId) return undefined\n return conversationId.startsWith(DISCORD_CONVERSATION_PREFIX)\n ? conversationId.slice(DISCORD_CONVERSATION_PREFIX.length)\n : conversationId\n}\n\n/**\n * Where an outbound message is actually posted, in priority order.\n *\n * `metadata.to` is in this list because of what QA found on #4391: the hub's\n * `test-send` route puts the operator's recipient in `metadata.to`, nothing\n * here read it, and every send therefore went to `defaultChannelId` while the\n * endpoint answered `200 sent`. Validating a recipient and then discarding it\n * is worse than rejecting it \u2014 an operator test-sending to one channel got a\n * success for a message that landed in another.\n *\n * `conversationId` deliberately outranks `metadata.to`: on a reply the thread\n * mapping is authoritative, and a stray `to` must never redirect a reply out of\n * its own conversation. `metadata.to` only decides when there is no\n * conversation to belong to, which is exactly the test-send case.\n *\n * With `capabilities.recipientFormat: 'provider-native'` the hub has already\n * applied its transport-safety checks to this value, but it stays untrusted\n * input: it reaches `/channels/{id}/messages` and a wrong-but-well-formed id\n * simply fails at Discord, which is the honest outcome.\n */\nfunction resolveTargetChannelId(input: SendMessageInput, defaultChannelId: string | undefined): string | undefined {\n const fromMeta = input.metadata?.discordChannelId\n if (typeof fromMeta === 'string' && fromMeta.length > 0) return fromMeta\n const fromConversation = stripConversationPrefix(input.conversationId)\n if (fromConversation && fromConversation.length > 0) return fromConversation\n const fromRecipient = input.metadata?.to\n if (typeof fromRecipient === 'string' && fromRecipient.length > 0) return fromRecipient\n return defaultChannelId\n}\n\nfunction pickDiscordMessage(raw: InboundMessage): DiscordMessageObject {\n const candidate = (raw.raw?.discordMessage ?? raw.raw) as DiscordMessageObject | undefined\n if (!candidate || typeof candidate !== 'object' || typeof candidate.id !== 'string') {\n throw new Error('[internal] Discord normalizeInbound requires `raw.discordMessage` to be a Discord message object')\n }\n return candidate\n}\n\nlet cachedAdapter: DiscordChannelAdapter | null = null\n\nexport function getDiscordChannelAdapter(): DiscordChannelAdapter {\n if (!cachedAdapter) cachedAdapter = new DiscordChannelAdapter()\n return cachedAdapter\n}\n\nexport { DiscordChannelAdapter }\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
const DISCORD_PROVIDER_KEY = "discord";
|
|
2
2
|
const DISCORD_CHANNEL_TYPE = "discord";
|
|
3
|
+
function discordChannelExternalIdentifier(applicationId) {
|
|
4
|
+
return `${DISCORD_PROVIDER_KEY}:${applicationId}`;
|
|
5
|
+
}
|
|
3
6
|
export {
|
|
4
7
|
DISCORD_CHANNEL_TYPE,
|
|
5
|
-
DISCORD_PROVIDER_KEY
|
|
8
|
+
DISCORD_PROVIDER_KEY,
|
|
9
|
+
discordChannelExternalIdentifier
|
|
6
10
|
};
|
|
7
11
|
//# sourceMappingURL=channel-identity.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_discord/lib/channel-identity.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * The two strings that identify this provider to the hub, in one dependency-free\n * module so the adapter and every caller that has to declare them import the\n * same literal.\n *\n * They were duplicated as inline literals before, and #5601 is what that costs:\n * the AI auto-reply composed a `channel.discord` message without telling the\n * validator which channel type it originated from. `channelTypeRequiresExternalEmail`\n * fails closed on an absent type, so the hub demanded an `externalEmail` from a\n * Discord sender \u2014 who is a snowflake and has no address \u2014 and every automatic\n * reply died in validation.\n */\n\n/** Provider key persisted on `communication_channels.provider_key`. */\nexport const DISCORD_PROVIDER_KEY = 'discord'\n\n/**\n * Channel type the hub reasons about. It is the value\n * `NON_EMAIL_SENDER_CHANNEL_TYPES` recognizes (`messages/lib/channel-sender-identity.ts`),\n * so any compose that omits it silently re-acquires the `externalEmail`\n * requirement this provider cannot satisfy.\n */\nexport const DISCORD_CHANNEL_TYPE = 'discord'\n"],
|
|
5
|
-
"mappings": "AAcO,MAAM,uBAAuB;AAQ7B,MAAM,uBAAuB;",
|
|
4
|
+
"sourcesContent": ["/**\n * The two strings that identify this provider to the hub, in one dependency-free\n * module so the adapter and every caller that has to declare them import the\n * same literal.\n *\n * They were duplicated as inline literals before, and #5601 is what that costs:\n * the AI auto-reply composed a `channel.discord` message without telling the\n * validator which channel type it originated from. `channelTypeRequiresExternalEmail`\n * fails closed on an absent type, so the hub demanded an `externalEmail` from a\n * Discord sender \u2014 who is a snowflake and has no address \u2014 and every automatic\n * reply died in validation.\n */\n\n/** Provider key persisted on `communication_channels.provider_key`. */\nexport const DISCORD_PROVIDER_KEY = 'discord'\n\n/**\n * Channel type the hub reasons about. It is the value\n * `NON_EMAIL_SENDER_CHANNEL_TYPES` recognizes (`messages/lib/channel-sender-identity.ts`),\n * so any compose that omits it silently re-acquires the `externalEmail`\n * requirement this provider cannot satisfy.\n */\nexport const DISCORD_CHANNEL_TYPE = 'discord'\n\n/**\n * Stable `CommunicationChannel.externalIdentifier` for a Discord bot. The hub\n * heals a reconnect in place only when the identifier matches, and a Discord\n * credential bag has no email-shaped key to derive one from (#4977).\n */\nexport function discordChannelExternalIdentifier(applicationId: string): string {\n return `${DISCORD_PROVIDER_KEY}:${applicationId}`\n}\n"],
|
|
5
|
+
"mappings": "AAcO,MAAM,uBAAuB;AAQ7B,MAAM,uBAAuB;AAO7B,SAAS,iCAAiC,eAA+B;AAC9E,SAAO,GAAG,oBAAoB,IAAI,aAAa;AACjD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_discord/workers/discord-gateway.ts"],
|
|
4
|
-
"sourcesContent": ["import { createHash } from 'node:crypto'\nimport type { QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'\nimport {\n COMMUNICATION_CHANNELS_QUEUES,\n getCommunicationChannelsQueue,\n} from '@open-mercato/core/modules/communication_channels/lib/queue'\nimport { emitCommunicationChannelsEvent } from '@open-mercato/core/modules/communication_channels/events'\nimport { parseDiscordCredentialsOrThrow, discordChannelStateSchema } from '../lib/credentials'\nimport {\n getDiscordGatewayClient,\n type DiscordGatewayHandle,\n type GatewayResumeState,\n} from '../lib/discord-gateway-client'\nimport {\n persistDiscordChannelState,\n quarantineDiscordChannel,\n type DiscordChannelScope,\n type DiscordChannelStatePatch,\n} from '../lib/channel-state-store'\nimport { buildInboundMessageJob, buildReactionJob, type GatewayChannelScope } from '../lib/gateway-bridge'\n\nconst logger = createLogger('channel_discord').child({ component: 'gateway-worker' })\n\n/**\n * Long-running Discord Gateway bridge worker (SPEC 2026-06-19 \u00A7 Gateway worker).\n *\n * This is a *provider-owned* long-running worker \u2014 a novel pattern relative to\n * the email providers (which are poll/push-driven by the hub). A worker file\n * under `workers/` is auto-discovered, so shipping one from a provider package is\n * allowed by the framework. `concurrency: 1` enforces the single-identify-per-bot\n * discipline Discord requires.\n *\n * The job opens one Gateway WebSocket per active `discord` channel and bridges\n * `MESSAGE_CREATE` / reaction events into the hub's existing queues (inbound +\n * reactions) \u2014 the same jobs the webhook route enqueues, so the hub stays\n * unchanged. Events the bot authored are dropped (feedback-loop guard); the hub\n * dedups the rest.\n *\n * Set `OM_CHANNEL_DISCORD_GATEWAY_DISABLED=1` to skip opening sockets (CI /\n * send-only deployments).\n */\n/** Queue that the gateway bridge consumes. Shared with `cli.ts` (start-gateway). */\nexport const CHANNEL_DISCORD_GATEWAY_QUEUE = 'channel_discord_gateway'\n\nexport const metadata: WorkerMeta = {\n queue: CHANNEL_DISCORD_GATEWAY_QUEUE,\n id: 'channel_discord:gateway',\n concurrency: 1,\n}\n\ntype HandlerContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\ntype GatewayJobPayload = {\n /** Optional tenant filter; when absent, all active discord channels connect. */\n tenantId?: string\n /**\n * Optional organization filter, narrowing within `tenantId`. Both the channel\n * query and the reconciliation honour it, so a job scoped to one organization\n * never opens, and never tears down, a socket belonging to another.\n */\n organizationId?: string | null\n}\n\nexport interface GatewayConnectionEntry {\n handle: DiscordGatewayHandle\n tenantId: string\n /** Owning organization, or `null` for a tenant-wide channel. Scopes teardown. */\n organizationId: string | null\n /** SHA-256 of the bot token, never the token itself \u2014 see `botTokenFingerprint`. */\n botTokenFingerprint?: string\n}\n\n/**\n * Stable, non-reversible identity for a bot token, used to detect that two\n * channel rows are really the same Discord bot.\n *\n * The hub derives a channel's `externalIdentifier` by sniffing the credential\n * bag for email-shaped keys, which Discord has none of, so every reconnect\n * inserts a fresh row instead of healing the existing one. Until that is fixed\n * hub-side, two rows for one bot would each open a socket and IDENTIFY\n * independently, defeating the single-identify discipline `concurrency: 1`\n * exists to enforce. Hashing keeps the token out of the registry and the logs.\n */\nexport function botTokenFingerprint(botToken: string): string {\n return createHash('sha256').update(botToken).digest('hex')\n}\n\n/**\n * Whether another channel already holds a live session for the same bot.\n * Pure over its arguments so the guard is unit-testable without sockets.\n */\nexport function findChannelWithSameBot(\n channelId: string,\n fingerprint: string,\n connections: Map<string, GatewayConnectionEntry> = activeConnections,\n): string | null {\n for (const [otherId, entry] of connections) {\n if (otherId === channelId) continue\n if (entry.botTokenFingerprint !== fingerprint) continue\n if (!isConnectionLive(entry)) continue\n return otherId\n }\n return null\n}\n\n// Module-level registry so a re-run replaces an existing connection instead of\n// opening a second socket for the same channel (single-identify discipline).\n// Keyed by channel id; carries tenantId so a per-tenant reconciliation never\n// tears down another tenant's sockets.\nconst activeConnections = new Map<string, GatewayConnectionEntry>()\n\n// Serializes resume-state writes per channel. The socket callbacks that produce\n// them (READY on every (re)connect) fire independently of the job loop, so\n// without a chain two callbacks for the same channel could interleave their\n// read-modify-write and lose the fresher one.\nconst pendingStateWrites = new Map<string, Promise<unknown>>()\n\n/**\n * Whether a registry entry still holds a running session. A session that is\n * merely reconnecting with backoff after a resumable close reports `true` \u2014 it\n * heals itself and MUST NOT be torn down and replaced by the refresh job.\n */\nexport function isConnectionLive(entry: GatewayConnectionEntry | undefined): boolean {\n if (!entry) return false\n try {\n return entry.handle.isActive()\n } catch {\n return false\n }\n}\n\n/** The scope a reconciliation run is allowed to tear connections down within. */\nexport interface GatewayReconcileScope {\n tenantId?: string\n organizationId?: string | null\n}\n\n/**\n * The channel query for one job payload. Every scope key the payload carries\n * narrows the query: a job scoped to an organization must connect that\n * organization's channels and no others, and the same scope is then handed to\n * the reconciliation so it tears down exactly the set it queried.\n */\nexport function buildGatewayChannelFilter(scope: GatewayReconcileScope): Record<string, unknown> {\n const filter: Record<string, unknown> = { providerKey: 'discord', isActive: true, deletedAt: null }\n if (scope.tenantId) filter.tenantId = scope.tenantId\n if (scope.organizationId != null) filter.organizationId = scope.organizationId\n return filter\n}\n\n/**\n * Close + drop any live connection whose channel is no longer in the active set\n * (deactivated / soft-deleted / re-scoped). Without this the socket + heartbeat\n * timer would leak forever after a channel is disconnected. When `scope` names a\n * tenant and/or an organization, only connections inside that scope are eligible\n * for teardown \u2014 a scoped refresh reconciles exactly the set it queried and\n * never touches another tenant's or organization's sockets. Returns the ids\n * reconciled away. Pure over its arguments so it is unit-testable.\n */\nexport function reconcileGatewayConnections(\n activeChannelIds: Set<string>,\n connections: Map<string, GatewayConnectionEntry> = activeConnections,\n scope: GatewayReconcileScope = {},\n): string[] {\n const removed: string[] = []\n for (const [channelId, entry] of connections) {\n if (activeChannelIds.has(channelId)) continue\n if (scope.tenantId && entry.tenantId !== scope.tenantId) continue\n if (scope.organizationId != null && entry.organizationId !== scope.organizationId) continue\n try {\n entry.handle.close()\n } catch {\n /* best-effort close */\n }\n connections.delete(channelId)\n removed.push(channelId)\n }\n return removed\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n}\n\nexport default async function handle(job: QueuedJob<GatewayJobPayload>, ctx: HandlerContext): Promise<void> {\n if (parseBooleanWithDefault(process.env.OM_CHANNEL_DISCORD_GATEWAY_DISABLED, false)) {\n logger.info('gateway disabled via OM_CHANNEL_DISCORD_GATEWAY_DISABLED \u2014 skipping connect')\n return\n }\n\n const em = (ctx.resolve('em') as EntityManager).fork()\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.resolve<CredentialsServiceLike>('integrationCredentialsService')\n } catch {\n credentialsService = null\n }\n if (!credentialsService) {\n logger.warn('integrationCredentialsService unavailable \u2014 cannot resolve bot tokens')\n return\n }\n\n // A scoped job narrows the query by every scope key it carries \u2014 a payload\n // that names an organization must not silently connect the whole tenant.\n const scope: GatewayReconcileScope = {\n tenantId: job.payload?.tenantId,\n organizationId: job.payload?.organizationId ?? null,\n }\n\n const channels = (await findWithDecryption(\n em,\n CommunicationChannel,\n buildGatewayChannelFilter(scope),\n )) as CommunicationChannel[]\n\n // The refresh job is a *reconciler*, not a re-connector: a channel whose\n // session is still running is left alone. Restarting it (the previous\n // behaviour) tore down healthy sockets on every tick of the CLI's 60s refresh\n // and forced a fresh IDENTIFY each time, which burns Discord's session-start\n // budget and drops the dispatches in flight.\n let started = 0\n let kept = 0\n let quarantined = 0\n for (const channel of channels) {\n if (isConnectionLive(activeConnections.get(channel.id))) {\n kept += 1\n continue\n }\n // A channel Discord fatally rejected (bad token / disallowed intents) stays\n // parked until an operator reconnects it. Retrying cannot fix either cause,\n // and re-IDENTIFYing every tick burns the bot's daily session-start budget.\n if (channel.status === 'requires_reauth') {\n quarantined += 1\n continue\n }\n await startChannelConnection(channel, credentialsService, em)\n started += 1\n }\n\n // Full reconciliation: close sockets for channels that dropped out of the\n // active set since the last run (deactivated / soft-deleted). A scoped run\n // reconciles within exactly the scope it queried, so a per-tenant or\n // per-organization refresh never tears down sockets it did not consider.\n const activeIds = new Set(channels.map((channel) => channel.id))\n const removed = reconcileGatewayConnections(activeIds, activeConnections, scope)\n if (removed.length > 0) {\n logger.info('reconciled away stale discord gateway connections', { channelIds: removed })\n }\n logger.debug('discord gateway reconciliation finished', { started, kept, quarantined, removed: removed.length })\n}\n\nasync function startChannelConnection(\n channel: CommunicationChannel,\n credentialsService: CredentialsServiceLike,\n em: EntityManager,\n): Promise<void> {\n const scope: GatewayChannelScope = {\n channelId: channel.id,\n channelType: channel.channelType,\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? null,\n }\n\n let credentials: Record<string, unknown> | null = null\n try {\n credentials = await credentialsService.resolve('channel_discord', {\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? channel.tenantId,\n userId: channel.userId ?? null,\n })\n } catch (err) {\n logger.warn('failed to resolve discord credentials for channel', { channelId: channel.id, err })\n return\n }\n if (!credentials) return\n\n let botToken: string\n try {\n botToken = parseDiscordCredentialsOrThrow(credentials).botToken\n } catch (err) {\n logger.warn('invalid discord credentials for channel', { channelId: channel.id, err })\n return\n }\n\n const fingerprint = botTokenFingerprint(botToken)\n const duplicateOf = findChannelWithSameBot(channel.id, fingerprint)\n if (duplicateOf) {\n logger.warn('skipping discord gateway connect \u2014 another channel already serves this bot', {\n channelId: channel.id,\n servedBy: duplicateOf,\n })\n return\n }\n\n const channelState = discordChannelStateSchema.parse(channel.channelState ?? {})\n const resumeState: GatewayResumeState = {\n sessionId: channelState.sessionId,\n sequence: channelState.sequence ?? null,\n resumeGatewayUrl: channelState.resumeGatewayUrl,\n }\n\n // Only reached for a channel with no live session (see `handle`): drop the\n // dead handle, if any, before opening its replacement.\n const stale = activeConnections.get(channel.id)\n if (stale) {\n try {\n stale.handle.close()\n } catch {\n /* best-effort close of an already-dead session */\n }\n activeConnections.delete(channel.id)\n }\n\n const stateScope: DiscordChannelScope = {\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? null,\n }\n\n const inboundQueue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.inbound)\n const reactionsQueue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.reactions)\n let botUserId: string | undefined = channelState.botUserId\n\n const handle = getDiscordGatewayClient().connect({\n botToken,\n resumeState,\n onMessage: async (message) => {\n // Log receive / drop / enqueue separately. Without these three lines a lost\n // inbound message is indistinguishable from a message correctly dropped by\n // the bot-self guard, and both are indistinguishable from a dead socket \u2014\n // diagnosing it required an external WebSocket probe during QA.\n logger.debug('discord gateway received message', { channelId: channel.id, messageId: message.id })\n const jobPayload = buildInboundMessageJob({ message, channel: scope, botUserId })\n if (!jobPayload) {\n logger.debug('discord inbound message dropped \u2014 authored by this bot', {\n channelId: channel.id,\n messageId: message.id,\n })\n return\n }\n await inboundQueue.enqueue(jobPayload as unknown as Record<string, unknown>)\n logger.debug('discord inbound message enqueued', {\n channelId: channel.id,\n messageId: message.id,\n queue: COMMUNICATION_CHANNELS_QUEUES.inbound,\n })\n },\n onReaction: async (reaction, action) => {\n const reactionJob = await buildReactionJob({ reaction, action, channel: scope, botUserId })\n if (!reactionJob) return\n await reactionsQueue.enqueue(reactionJob as unknown as Record<string, unknown>)\n },\n onReady: async ({ botUserId: readyBotUserId, resumeState: freshResumeState }) => {\n botUserId = readyBotUserId || botUserId\n await persistChannelState(em, channel.id, stateScope, { ...freshResumeState, botUserId })\n logger.info('discord gateway ready', { channelId: channel.id })\n },\n onRequiresReauth: async ({ code }) => {\n logger.warn('discord gateway fatal close \u2014 quarantining channel', { channelId: channel.id, code })\n // Persist the status BEFORE dropping the handle. Emitting the event alone\n // is not enough: nothing subscribes to it to change channel state, so the\n // next reconciliation tick would see \"no live session\" and IDENTIFY again,\n // forever, against a token or intent configuration that cannot self-heal.\n await quarantineChannel(em, channel.id, stateScope, `gateway_close_${code}`)\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: 'discord',\n channelType: channel.channelType,\n reason: `gateway_close_${code}`,\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? null,\n },\n { persistent: true },\n )\n activeConnections.delete(channel.id)\n },\n })\n activeConnections.set(channel.id, {\n handle,\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? null,\n botTokenFingerprint: fingerprint,\n })\n}\n\n/**\n * Persist gateway resume state for one channel, tenant-scoped and serialized.\n *\n * The write is queued behind any write already in flight for the same channel so\n * two socket callbacks cannot interleave their read-modify-write, and it runs\n * through `persistDiscordChannelState`, which filters by `tenant_id` /\n * `organization_id` and only merges the gateway-owned keys.\n */\nasync function persistChannelState(\n em: EntityManager,\n channelId: string,\n scope: DiscordChannelScope,\n patch: DiscordChannelStatePatch,\n): Promise<void> {\n const previous = pendingStateWrites.get(channelId) ?? Promise.resolve()\n const next = previous\n .catch(() => undefined)\n .then(async () => {\n try {\n const result = await persistDiscordChannelState({ em, channelId, scope, patch })\n if (result === 'not_found') {\n logger.warn('discord channel not found in scope while persisting resume state', { channelId })\n }\n } catch (err) {\n // Resume-state persistence is best-effort \u2014 a failure just means the next\n // connect re-identifies fresh instead of resuming.\n logger.warn('failed to persist discord gateway resume state', { channelId, err })\n }\n })\n pendingStateWrites.set(channelId, next)\n await next\n if (pendingStateWrites.get(channelId) === next) pendingStateWrites.delete(channelId)\n}\n\n/**\n * Park a fatally-rejected channel, serialized behind the same per-channel write\n * chain as the resume-state writes so a quarantine and a late `READY` callback\n * cannot interleave their read-modify-write on the same row.\n */\nasync function quarantineChannel(\n em: EntityManager,\n channelId: string,\n scope: DiscordChannelScope,\n reason: string,\n): Promise<void> {\n const previous = pendingStateWrites.get(channelId) ?? Promise.resolve()\n const next = previous\n .catch(() => undefined)\n .then(async () => {\n try {\n const result = await quarantineDiscordChannel({ em, channelId, scope, reason })\n if (result === 'not_found') {\n logger.warn('discord channel not found in scope while quarantining', { channelId })\n }\n } catch (err) {\n logger.warn('failed to quarantine discord channel after fatal close', { channelId, reason, err })\n }\n })\n pendingStateWrites.set(channelId, next)\n await next\n if (pendingStateWrites.get(channelId) === next) pendingStateWrites.delete(channelId)\n}\n"],
|
|
4
|
+
"sourcesContent": ["import { createHash } from 'node:crypto'\nimport type { QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'\nimport {\n COMMUNICATION_CHANNELS_QUEUES,\n getCommunicationChannelsQueue,\n} from '@open-mercato/core/modules/communication_channels/lib/queue'\nimport { emitCommunicationChannelsEvent } from '@open-mercato/core/modules/communication_channels/events'\nimport { parseDiscordCredentialsOrThrow, discordChannelStateSchema } from '../lib/credentials'\nimport {\n getDiscordGatewayClient,\n type DiscordGatewayHandle,\n type GatewayResumeState,\n} from '../lib/discord-gateway-client'\nimport {\n persistDiscordChannelState,\n quarantineDiscordChannel,\n type DiscordChannelScope,\n type DiscordChannelStatePatch,\n} from '../lib/channel-state-store'\nimport { buildInboundMessageJob, buildReactionJob, type GatewayChannelScope } from '../lib/gateway-bridge'\n\nconst logger = createLogger('channel_discord').child({ component: 'gateway-worker' })\n\n/**\n * Long-running Discord Gateway bridge worker (SPEC 2026-06-19 \u00A7 Gateway worker).\n *\n * This is a *provider-owned* long-running worker \u2014 a novel pattern relative to\n * the email providers (which are poll/push-driven by the hub). A worker file\n * under `workers/` is auto-discovered, so shipping one from a provider package is\n * allowed by the framework. `concurrency: 1` enforces the single-identify-per-bot\n * discipline Discord requires.\n *\n * The job opens one Gateway WebSocket per active `discord` channel and bridges\n * `MESSAGE_CREATE` / reaction events into the hub's existing queues (inbound +\n * reactions) \u2014 the same jobs the webhook route enqueues, so the hub stays\n * unchanged. Events the bot authored are dropped (feedback-loop guard); the hub\n * dedups the rest.\n *\n * Set `OM_CHANNEL_DISCORD_GATEWAY_DISABLED=1` to skip opening sockets (CI /\n * send-only deployments).\n */\n/** Queue that the gateway bridge consumes. Shared with `cli.ts` (start-gateway). */\nexport const CHANNEL_DISCORD_GATEWAY_QUEUE = 'channel_discord_gateway'\n\nexport const metadata: WorkerMeta = {\n queue: CHANNEL_DISCORD_GATEWAY_QUEUE,\n id: 'channel_discord:gateway',\n concurrency: 1,\n}\n\ntype HandlerContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\ntype GatewayJobPayload = {\n /** Optional tenant filter; when absent, all active discord channels connect. */\n tenantId?: string\n /**\n * Optional organization filter, narrowing within `tenantId`. Both the channel\n * query and the reconciliation honour it, so a job scoped to one organization\n * never opens, and never tears down, a socket belonging to another.\n */\n organizationId?: string | null\n}\n\nexport interface GatewayConnectionEntry {\n handle: DiscordGatewayHandle\n tenantId: string\n /** Owning organization, or `null` for a tenant-wide channel. Scopes teardown. */\n organizationId: string | null\n /** SHA-256 of the bot token, never the token itself \u2014 see `botTokenFingerprint`. */\n botTokenFingerprint?: string\n}\n\n/**\n * Stable, non-reversible identity for a bot token, used to detect that two\n * channel rows are really the same Discord bot.\n *\n * A reconnect now heals the existing row (`validateCredentials` reports\n * `discord:<applicationId>` as the channel identity), but deployments can still\n * hold duplicate rows created before that, and two rows for one bot would each\n * open a socket and IDENTIFY independently, defeating the single-identify\n * discipline `concurrency: 1` exists to enforce. Hashing keeps the token out of\n * the registry and the logs.\n */\nexport function botTokenFingerprint(botToken: string): string {\n return createHash('sha256').update(botToken).digest('hex')\n}\n\n/**\n * Whether another channel already holds a live session for the same bot.\n * Pure over its arguments so the guard is unit-testable without sockets.\n */\nexport function findChannelWithSameBot(\n channelId: string,\n fingerprint: string,\n connections: Map<string, GatewayConnectionEntry> = activeConnections,\n): string | null {\n for (const [otherId, entry] of connections) {\n if (otherId === channelId) continue\n if (entry.botTokenFingerprint !== fingerprint) continue\n if (!isConnectionLive(entry)) continue\n return otherId\n }\n return null\n}\n\n// Module-level registry so a re-run replaces an existing connection instead of\n// opening a second socket for the same channel (single-identify discipline).\n// Keyed by channel id; carries tenantId so a per-tenant reconciliation never\n// tears down another tenant's sockets.\nconst activeConnections = new Map<string, GatewayConnectionEntry>()\n\n// Serializes resume-state writes per channel. The socket callbacks that produce\n// them (READY on every (re)connect) fire independently of the job loop, so\n// without a chain two callbacks for the same channel could interleave their\n// read-modify-write and lose the fresher one.\nconst pendingStateWrites = new Map<string, Promise<unknown>>()\n\n/**\n * Whether a registry entry still holds a running session. A session that is\n * merely reconnecting with backoff after a resumable close reports `true` \u2014 it\n * heals itself and MUST NOT be torn down and replaced by the refresh job.\n */\nexport function isConnectionLive(entry: GatewayConnectionEntry | undefined): boolean {\n if (!entry) return false\n try {\n return entry.handle.isActive()\n } catch {\n return false\n }\n}\n\n/** The scope a reconciliation run is allowed to tear connections down within. */\nexport interface GatewayReconcileScope {\n tenantId?: string\n organizationId?: string | null\n}\n\n/**\n * The channel query for one job payload. Every scope key the payload carries\n * narrows the query: a job scoped to an organization must connect that\n * organization's channels and no others, and the same scope is then handed to\n * the reconciliation so it tears down exactly the set it queried.\n */\nexport function buildGatewayChannelFilter(scope: GatewayReconcileScope): Record<string, unknown> {\n const filter: Record<string, unknown> = { providerKey: 'discord', isActive: true, deletedAt: null }\n if (scope.tenantId) filter.tenantId = scope.tenantId\n if (scope.organizationId != null) filter.organizationId = scope.organizationId\n return filter\n}\n\n/**\n * Close + drop any live connection whose channel is no longer in the active set\n * (deactivated / soft-deleted / re-scoped). Without this the socket + heartbeat\n * timer would leak forever after a channel is disconnected. When `scope` names a\n * tenant and/or an organization, only connections inside that scope are eligible\n * for teardown \u2014 a scoped refresh reconciles exactly the set it queried and\n * never touches another tenant's or organization's sockets. Returns the ids\n * reconciled away. Pure over its arguments so it is unit-testable.\n */\nexport function reconcileGatewayConnections(\n activeChannelIds: Set<string>,\n connections: Map<string, GatewayConnectionEntry> = activeConnections,\n scope: GatewayReconcileScope = {},\n): string[] {\n const removed: string[] = []\n for (const [channelId, entry] of connections) {\n if (activeChannelIds.has(channelId)) continue\n if (scope.tenantId && entry.tenantId !== scope.tenantId) continue\n if (scope.organizationId != null && entry.organizationId !== scope.organizationId) continue\n try {\n entry.handle.close()\n } catch {\n /* best-effort close */\n }\n connections.delete(channelId)\n removed.push(channelId)\n }\n return removed\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n}\n\nexport default async function handle(job: QueuedJob<GatewayJobPayload>, ctx: HandlerContext): Promise<void> {\n if (parseBooleanWithDefault(process.env.OM_CHANNEL_DISCORD_GATEWAY_DISABLED, false)) {\n logger.info('gateway disabled via OM_CHANNEL_DISCORD_GATEWAY_DISABLED \u2014 skipping connect')\n return\n }\n\n const em = (ctx.resolve('em') as EntityManager).fork()\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.resolve<CredentialsServiceLike>('integrationCredentialsService')\n } catch {\n credentialsService = null\n }\n if (!credentialsService) {\n logger.warn('integrationCredentialsService unavailable \u2014 cannot resolve bot tokens')\n return\n }\n\n // A scoped job narrows the query by every scope key it carries \u2014 a payload\n // that names an organization must not silently connect the whole tenant.\n const scope: GatewayReconcileScope = {\n tenantId: job.payload?.tenantId,\n organizationId: job.payload?.organizationId ?? null,\n }\n\n const channels = (await findWithDecryption(\n em,\n CommunicationChannel,\n buildGatewayChannelFilter(scope),\n )) as CommunicationChannel[]\n\n // The refresh job is a *reconciler*, not a re-connector: a channel whose\n // session is still running is left alone. Restarting it (the previous\n // behaviour) tore down healthy sockets on every tick of the CLI's 60s refresh\n // and forced a fresh IDENTIFY each time, which burns Discord's session-start\n // budget and drops the dispatches in flight.\n let started = 0\n let kept = 0\n let quarantined = 0\n for (const channel of channels) {\n if (isConnectionLive(activeConnections.get(channel.id))) {\n kept += 1\n continue\n }\n // A channel Discord fatally rejected (bad token / disallowed intents) stays\n // parked until an operator reconnects it. Retrying cannot fix either cause,\n // and re-IDENTIFYing every tick burns the bot's daily session-start budget.\n if (channel.status === 'requires_reauth') {\n quarantined += 1\n continue\n }\n await startChannelConnection(channel, credentialsService, em)\n started += 1\n }\n\n // Full reconciliation: close sockets for channels that dropped out of the\n // active set since the last run (deactivated / soft-deleted). A scoped run\n // reconciles within exactly the scope it queried, so a per-tenant or\n // per-organization refresh never tears down sockets it did not consider.\n const activeIds = new Set(channels.map((channel) => channel.id))\n const removed = reconcileGatewayConnections(activeIds, activeConnections, scope)\n if (removed.length > 0) {\n logger.info('reconciled away stale discord gateway connections', { channelIds: removed })\n }\n logger.debug('discord gateway reconciliation finished', { started, kept, quarantined, removed: removed.length })\n}\n\nasync function startChannelConnection(\n channel: CommunicationChannel,\n credentialsService: CredentialsServiceLike,\n em: EntityManager,\n): Promise<void> {\n const scope: GatewayChannelScope = {\n channelId: channel.id,\n channelType: channel.channelType,\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? null,\n }\n\n let credentials: Record<string, unknown> | null = null\n try {\n credentials = await credentialsService.resolve('channel_discord', {\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? channel.tenantId,\n userId: channel.userId ?? null,\n })\n } catch (err) {\n logger.warn('failed to resolve discord credentials for channel', { channelId: channel.id, err })\n return\n }\n if (!credentials) return\n\n let botToken: string\n try {\n botToken = parseDiscordCredentialsOrThrow(credentials).botToken\n } catch (err) {\n logger.warn('invalid discord credentials for channel', { channelId: channel.id, err })\n return\n }\n\n const fingerprint = botTokenFingerprint(botToken)\n const duplicateOf = findChannelWithSameBot(channel.id, fingerprint)\n if (duplicateOf) {\n logger.warn('skipping discord gateway connect \u2014 another channel already serves this bot', {\n channelId: channel.id,\n servedBy: duplicateOf,\n })\n return\n }\n\n const channelState = discordChannelStateSchema.parse(channel.channelState ?? {})\n const resumeState: GatewayResumeState = {\n sessionId: channelState.sessionId,\n sequence: channelState.sequence ?? null,\n resumeGatewayUrl: channelState.resumeGatewayUrl,\n }\n\n // Only reached for a channel with no live session (see `handle`): drop the\n // dead handle, if any, before opening its replacement.\n const stale = activeConnections.get(channel.id)\n if (stale) {\n try {\n stale.handle.close()\n } catch {\n /* best-effort close of an already-dead session */\n }\n activeConnections.delete(channel.id)\n }\n\n const stateScope: DiscordChannelScope = {\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? null,\n }\n\n const inboundQueue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.inbound)\n const reactionsQueue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.reactions)\n let botUserId: string | undefined = channelState.botUserId\n\n const handle = getDiscordGatewayClient().connect({\n botToken,\n resumeState,\n onMessage: async (message) => {\n // Log receive / drop / enqueue separately. Without these three lines a lost\n // inbound message is indistinguishable from a message correctly dropped by\n // the bot-self guard, and both are indistinguishable from a dead socket \u2014\n // diagnosing it required an external WebSocket probe during QA.\n logger.debug('discord gateway received message', { channelId: channel.id, messageId: message.id })\n const jobPayload = buildInboundMessageJob({ message, channel: scope, botUserId })\n if (!jobPayload) {\n logger.debug('discord inbound message dropped \u2014 authored by this bot', {\n channelId: channel.id,\n messageId: message.id,\n })\n return\n }\n await inboundQueue.enqueue(jobPayload as unknown as Record<string, unknown>)\n logger.debug('discord inbound message enqueued', {\n channelId: channel.id,\n messageId: message.id,\n queue: COMMUNICATION_CHANNELS_QUEUES.inbound,\n })\n },\n onReaction: async (reaction, action) => {\n const reactionJob = await buildReactionJob({ reaction, action, channel: scope, botUserId })\n if (!reactionJob) return\n await reactionsQueue.enqueue(reactionJob as unknown as Record<string, unknown>)\n },\n onReady: async ({ botUserId: readyBotUserId, resumeState: freshResumeState }) => {\n botUserId = readyBotUserId || botUserId\n await persistChannelState(em, channel.id, stateScope, { ...freshResumeState, botUserId })\n logger.info('discord gateway ready', { channelId: channel.id })\n },\n onRequiresReauth: async ({ code }) => {\n logger.warn('discord gateway fatal close \u2014 quarantining channel', { channelId: channel.id, code })\n // Persist the status BEFORE dropping the handle. Emitting the event alone\n // is not enough: nothing subscribes to it to change channel state, so the\n // next reconciliation tick would see \"no live session\" and IDENTIFY again,\n // forever, against a token or intent configuration that cannot self-heal.\n await quarantineChannel(em, channel.id, stateScope, `gateway_close_${code}`)\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: 'discord',\n channelType: channel.channelType,\n reason: `gateway_close_${code}`,\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? null,\n },\n { persistent: true },\n )\n activeConnections.delete(channel.id)\n },\n })\n activeConnections.set(channel.id, {\n handle,\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? null,\n botTokenFingerprint: fingerprint,\n })\n}\n\n/**\n * Persist gateway resume state for one channel, tenant-scoped and serialized.\n *\n * The write is queued behind any write already in flight for the same channel so\n * two socket callbacks cannot interleave their read-modify-write, and it runs\n * through `persistDiscordChannelState`, which filters by `tenant_id` /\n * `organization_id` and only merges the gateway-owned keys.\n */\nasync function persistChannelState(\n em: EntityManager,\n channelId: string,\n scope: DiscordChannelScope,\n patch: DiscordChannelStatePatch,\n): Promise<void> {\n const previous = pendingStateWrites.get(channelId) ?? Promise.resolve()\n const next = previous\n .catch(() => undefined)\n .then(async () => {\n try {\n const result = await persistDiscordChannelState({ em, channelId, scope, patch })\n if (result === 'not_found') {\n logger.warn('discord channel not found in scope while persisting resume state', { channelId })\n }\n } catch (err) {\n // Resume-state persistence is best-effort \u2014 a failure just means the next\n // connect re-identifies fresh instead of resuming.\n logger.warn('failed to persist discord gateway resume state', { channelId, err })\n }\n })\n pendingStateWrites.set(channelId, next)\n await next\n if (pendingStateWrites.get(channelId) === next) pendingStateWrites.delete(channelId)\n}\n\n/**\n * Park a fatally-rejected channel, serialized behind the same per-channel write\n * chain as the resume-state writes so a quarantine and a late `READY` callback\n * cannot interleave their read-modify-write on the same row.\n */\nasync function quarantineChannel(\n em: EntityManager,\n channelId: string,\n scope: DiscordChannelScope,\n reason: string,\n): Promise<void> {\n const previous = pendingStateWrites.get(channelId) ?? Promise.resolve()\n const next = previous\n .catch(() => undefined)\n .then(async () => {\n try {\n const result = await quarantineDiscordChannel({ em, channelId, scope, reason })\n if (result === 'not_found') {\n logger.warn('discord channel not found in scope while quarantining', { channelId })\n }\n } catch (err) {\n logger.warn('failed to quarantine discord channel after fatal close', { channelId, reason, err })\n }\n })\n pendingStateWrites.set(channelId, next)\n await next\n if (pendingStateWrites.get(channelId) === next) pendingStateWrites.delete(channelId)\n}\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": ["handle"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/channel-discord",
|
|
3
|
-
"version": "0.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7225.1.73190a4a95",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,14 +62,14 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@open-mercato/core": "0.8.1-develop.
|
|
66
|
-
"@open-mercato/queue": "0.8.1-develop.
|
|
67
|
-
"@open-mercato/ui": "0.8.1-develop.
|
|
65
|
+
"@open-mercato/core": "0.8.1-develop.7225.1.73190a4a95",
|
|
66
|
+
"@open-mercato/queue": "0.8.1-develop.7225.1.73190a4a95",
|
|
67
|
+
"@open-mercato/ui": "0.8.1-develop.7225.1.73190a4a95"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
71
|
-
"@open-mercato/ai-assistant": "0.8.1-develop.
|
|
72
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
71
|
+
"@open-mercato/ai-assistant": "0.8.1-develop.7225.1.73190a4a95",
|
|
72
|
+
"@open-mercato/shared": "0.8.1-develop.7225.1.73190a4a95",
|
|
73
73
|
"react": "^19.0.0",
|
|
74
74
|
"react-dom": "^19.0.0"
|
|
75
75
|
},
|
|
@@ -79,8 +79,8 @@
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@open-mercato/ai-assistant": "0.8.1-develop.
|
|
83
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
82
|
+
"@open-mercato/ai-assistant": "0.8.1-develop.7225.1.73190a4a95",
|
|
83
|
+
"@open-mercato/shared": "0.8.1-develop.7225.1.73190a4a95",
|
|
84
84
|
"@types/jest": "^30.0.0",
|
|
85
85
|
"@types/react": "^19.2.18",
|
|
86
86
|
"@types/react-dom": "^19.2.5",
|
|
@@ -214,6 +214,19 @@ describe('DiscordChannelAdapter.validateCredentials', () => {
|
|
|
214
214
|
expect(result.ok).toBe(true)
|
|
215
215
|
})
|
|
216
216
|
|
|
217
|
+
it('reports the application id as the stable channel identity so a reconnect heals in place', async () => {
|
|
218
|
+
stubRest()
|
|
219
|
+
const adapter = getDiscordChannelAdapter()
|
|
220
|
+
const first = await adapter.validateCredentials({ providerKey: 'discord', credentials, scope: { organizationId: 'o', tenantId: 't' } })
|
|
221
|
+
const rotated = await adapter.validateCredentials({
|
|
222
|
+
providerKey: 'discord',
|
|
223
|
+
credentials: { ...credentials, botToken: 'bot-token-rotated', guildId: '999' },
|
|
224
|
+
scope: { organizationId: 'o', tenantId: 't' },
|
|
225
|
+
})
|
|
226
|
+
expect(first.externalIdentifier).toBe('discord:123')
|
|
227
|
+
expect(rotated.externalIdentifier).toBe(first.externalIdentifier)
|
|
228
|
+
})
|
|
229
|
+
|
|
217
230
|
it('rejects a bad token with a field error', async () => {
|
|
218
231
|
stubRest({
|
|
219
232
|
async getCurrentUser() {
|
|
@@ -225,5 +238,6 @@ describe('DiscordChannelAdapter.validateCredentials', () => {
|
|
|
225
238
|
const result = await adapter.validateCredentials({ providerKey: 'discord', credentials, scope: { organizationId: 'o', tenantId: 't' } })
|
|
226
239
|
expect(result.ok).toBe(false)
|
|
227
240
|
expect(result.errors?.botToken).toBeTruthy()
|
|
241
|
+
expect(result.externalIdentifier).toBeUndefined()
|
|
228
242
|
})
|
|
229
243
|
})
|
|
@@ -22,7 +22,11 @@ import type {
|
|
|
22
22
|
VerifyWebhookInput,
|
|
23
23
|
} from '@open-mercato/core/modules/communication_channels/lib/adapter'
|
|
24
24
|
import { discordCapabilities } from './capabilities'
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
DISCORD_CHANNEL_TYPE,
|
|
27
|
+
DISCORD_PROVIDER_KEY,
|
|
28
|
+
discordChannelExternalIdentifier,
|
|
29
|
+
} from './channel-identity'
|
|
26
30
|
import { parseDiscordCredentialsOrThrow, discordCredentialsSchema } from './credentials'
|
|
27
31
|
import {
|
|
28
32
|
DiscordApiError,
|
|
@@ -285,7 +289,7 @@ class DiscordChannelAdapter implements ChannelAdapter {
|
|
|
285
289
|
}
|
|
286
290
|
try {
|
|
287
291
|
await getDiscordRestClient().getCurrentUser({ botToken: parsed.data.botToken })
|
|
288
|
-
return { ok: true }
|
|
292
|
+
return { ok: true, externalIdentifier: discordChannelExternalIdentifier(parsed.data.applicationId) }
|
|
289
293
|
} catch (error) {
|
|
290
294
|
const status = error instanceof DiscordApiError ? error.status : 0
|
|
291
295
|
return {
|
|
@@ -21,3 +21,12 @@ export const DISCORD_PROVIDER_KEY = 'discord'
|
|
|
21
21
|
* requirement this provider cannot satisfy.
|
|
22
22
|
*/
|
|
23
23
|
export const DISCORD_CHANNEL_TYPE = 'discord'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Stable `CommunicationChannel.externalIdentifier` for a Discord bot. The hub
|
|
27
|
+
* heals a reconnect in place only when the identifier matches, and a Discord
|
|
28
|
+
* credential bag has no email-shaped key to derive one from (#4977).
|
|
29
|
+
*/
|
|
30
|
+
export function discordChannelExternalIdentifier(applicationId: string): string {
|
|
31
|
+
return `${DISCORD_PROVIDER_KEY}:${applicationId}`
|
|
32
|
+
}
|
|
@@ -81,12 +81,12 @@ export interface GatewayConnectionEntry {
|
|
|
81
81
|
* Stable, non-reversible identity for a bot token, used to detect that two
|
|
82
82
|
* channel rows are really the same Discord bot.
|
|
83
83
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
84
|
+
* A reconnect now heals the existing row (`validateCredentials` reports
|
|
85
|
+
* `discord:<applicationId>` as the channel identity), but deployments can still
|
|
86
|
+
* hold duplicate rows created before that, and two rows for one bot would each
|
|
87
|
+
* open a socket and IDENTIFY independently, defeating the single-identify
|
|
88
|
+
* discipline `concurrency: 1` exists to enforce. Hashing keeps the token out of
|
|
89
|
+
* the registry and the logs.
|
|
90
90
|
*/
|
|
91
91
|
export function botTokenFingerprint(botToken: string): string {
|
|
92
92
|
return createHash('sha256').update(botToken).digest('hex')
|