@open-mercato/channel-discord 0.7.1-develop.7112.1.d486e6f1b2 → 0.7.1-develop.7120.1.a0154ac412
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/integration.js +1 -1
- package/dist/modules/channel_discord/integration.js.map +2 -2
- package/dist/modules/channel_discord/lib/adapter.js +1 -1
- package/dist/modules/channel_discord/lib/adapter.js.map +2 -2
- package/dist/modules/channel_discord/lib/capabilities.js +1 -1
- package/dist/modules/channel_discord/lib/capabilities.js.map +2 -2
- package/dist/modules/channel_discord/lib/convert-outbound.js +8 -5
- package/dist/modules/channel_discord/lib/convert-outbound.js.map +2 -2
- package/dist/modules/channel_discord/lib/discord-rest.js +1 -1
- package/dist/modules/channel_discord/lib/discord-rest.js.map +2 -2
- package/package.json +8 -8
- package/src/modules/channel_discord/integration.ts +3 -2
- package/src/modules/channel_discord/lib/__tests__/adapter.test.ts +23 -0
- package/src/modules/channel_discord/lib/__tests__/capabilities.test.ts +86 -16
- package/src/modules/channel_discord/lib/__tests__/convert-outbound.test.ts +40 -0
- package/src/modules/channel_discord/lib/adapter.ts +6 -1
- package/src/modules/channel_discord/lib/capabilities.ts +9 -10
- package/src/modules/channel_discord/lib/convert-outbound.ts +28 -8
- package/src/modules/channel_discord/lib/discord-rest.ts +10 -1
|
@@ -77,7 +77,7 @@ const integration = {
|
|
|
77
77
|
type: "text",
|
|
78
78
|
required: false,
|
|
79
79
|
placeholder: "123456789012345678",
|
|
80
|
-
helpText: "Default text channel the adapter posts to when an outbound message names no Discord channel.
|
|
80
|
+
helpText: "Default text channel the adapter posts to when an outbound message names no Discord channel. To verify it, call the hub test-send endpoint with an empty JSON body ({}) so no recipient is named, or pass a channel snowflake as the recipient to post somewhere else."
|
|
81
81
|
}
|
|
82
82
|
]
|
|
83
83
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/channel_discord/integration.ts"],
|
|
4
|
-
"sourcesContent": ["import { buildIntegrationDetailWidgetSpotId, type IntegrationBundle, type IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types'\n\nexport const channelDiscordDetailWidgetSpotId = buildIntegrationDetailWidgetSpotId('channel_discord')\n\nexport const integration: IntegrationDefinition = {\n id: 'channel_discord',\n title: 'Discord',\n // Kept within the 68\u2013132 character range the six sibling providers occupy: the\n // card grid gives every description the same fixed box, so a long one inflates\n // its whole row. The operational caveats it used to carry (gateway worker,\n // queue strategy) belong on the detail page and in the credential help texts,\n // which is where they now live.\n description: 'Two-way Discord bot channel: REST outbound, real-time Gateway inbound, optional AI auto-reply.',\n category: 'communication',\n hub: 'communication_channels',\n providerKey: 'discord',\n icon: 'discord',\n docsUrl: 'https://discord.com/developers/docs/intro',\n package: '@open-mercato/channel-discord',\n // The provider descriptor's own version, deliberately independent of the\n // monorepo release the package happens to ship in \u2014 a copied monorepo version\n // rots on every release bump and reads as a claim it is not making.\n version: '1.0.0',\n author: 'Open Mercato Team',\n company: 'Open Mercato',\n license: 'MIT',\n // `ai` returns here in the same change that makes auto-reply real: an agent the\n // repository ships, a service principal with real features, a settings surface\n // that arms it, and an approval surface for everything it must not send.\n tags: ['discord', 'chat', 'bot', 'gateway', 'communication', 'ai'],\n detailPage: {\n widgetSpotId: channelDiscordDetailWidgetSpotId,\n },\n apiVersions: [\n {\n id: 'v10',\n label: 'Discord API v10',\n status: 'stable',\n default: true,\n changelog: 'Discord API v10 (REST) + Gateway v10 real-time inbound + Ed25519-signed Interactions.',\n },\n ],\n credentials: {\n fields: [\n {\n key: 'botToken',\n label: 'Bot token',\n type: 'secret',\n required: true,\n helpText:\n 'Developer Portal \u2192 Applications \u2192 your app \u2192 Bot \u2192 Reset Token. Grants full control of the bot user. Stored encrypted at rest; never logged.',\n },\n {\n key: 'applicationId',\n label: 'Application ID',\n type: 'text',\n required: true,\n placeholder: '123456789012345678',\n helpText:\n 'Developer Portal \u2192 General Information \u2192 Application ID. Used to register slash commands'\n + ' (mercato channel_discord register-slash-commands) and to answer interactions.',\n },\n {\n key: 'publicKey',\n label: 'Public key',\n type: 'text',\n required: true,\n placeholder: '64-character hex Ed25519 public key',\n helpText:\n 'Developer Portal \u2192 General Information \u2192 Public Key. Verifies signed interaction requests; a verified'\n + ' slash command or button press is recorded in the inbox and answered back in Discord.',\n },\n {\n key: 'guildId',\n label: 'Guild (server) ID',\n type: 'text',\n required: false,\n placeholder: '123456789012345678',\n helpText: 'Recommended: scope the bot to a single server. Enable Developer Mode in Discord, right-click the server, Copy Server ID.',\n },\n {\n key: 'defaultChannelId',\n label: 'Default channel ID',\n type: 'text',\n required: false,\n placeholder: '123456789012345678',\n helpText:\n 'Default text channel the adapter posts to when an outbound message names no Discord channel.
|
|
5
|
-
"mappings": "AAAA,SAAS,0CAA8F;AAEhG,MAAM,mCAAmC,mCAAmC,iBAAiB;AAE7F,MAAM,cAAqC;AAAA,EAChD,IAAI;AAAA,EACJ,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMP,aAAa;AAAA,EACb,UAAU;AAAA,EACV,KAAK;AAAA,EACL,aAAa;AAAA,EACb,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA;AAAA;AAAA;AAAA,EAIT,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,SAAS;AAAA;AAAA;AAAA;AAAA,EAIT,MAAM,CAAC,WAAW,QAAQ,OAAO,WAAW,iBAAiB,IAAI;AAAA,EACjE,YAAY;AAAA,IACV,cAAc;AAAA,EAChB;AAAA,EACA,aAAa;AAAA,IACX;AAAA,MACE,IAAI;AAAA,MACJ,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,SAAS;AAAA,MACT,WAAW;AAAA,IACb;AAAA,EACF;AAAA,EACA,aAAa;AAAA,IACX,QAAQ;AAAA,MACN;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,MACJ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UACE;AAAA,MAEJ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UACE;AAAA,MAEJ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UACE;AAAA,
|
|
4
|
+
"sourcesContent": ["import { buildIntegrationDetailWidgetSpotId, type IntegrationBundle, type IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types'\n\nexport const channelDiscordDetailWidgetSpotId = buildIntegrationDetailWidgetSpotId('channel_discord')\n\nexport const integration: IntegrationDefinition = {\n id: 'channel_discord',\n title: 'Discord',\n // Kept within the 68\u2013132 character range the six sibling providers occupy: the\n // card grid gives every description the same fixed box, so a long one inflates\n // its whole row. The operational caveats it used to carry (gateway worker,\n // queue strategy) belong on the detail page and in the credential help texts,\n // which is where they now live.\n description: 'Two-way Discord bot channel: REST outbound, real-time Gateway inbound, optional AI auto-reply.',\n category: 'communication',\n hub: 'communication_channels',\n providerKey: 'discord',\n icon: 'discord',\n docsUrl: 'https://discord.com/developers/docs/intro',\n package: '@open-mercato/channel-discord',\n // The provider descriptor's own version, deliberately independent of the\n // monorepo release the package happens to ship in \u2014 a copied monorepo version\n // rots on every release bump and reads as a claim it is not making.\n version: '1.0.0',\n author: 'Open Mercato Team',\n company: 'Open Mercato',\n license: 'MIT',\n // `ai` returns here in the same change that makes auto-reply real: an agent the\n // repository ships, a service principal with real features, a settings surface\n // that arms it, and an approval surface for everything it must not send.\n tags: ['discord', 'chat', 'bot', 'gateway', 'communication', 'ai'],\n detailPage: {\n widgetSpotId: channelDiscordDetailWidgetSpotId,\n },\n apiVersions: [\n {\n id: 'v10',\n label: 'Discord API v10',\n status: 'stable',\n default: true,\n changelog: 'Discord API v10 (REST) + Gateway v10 real-time inbound + Ed25519-signed Interactions.',\n },\n ],\n credentials: {\n fields: [\n {\n key: 'botToken',\n label: 'Bot token',\n type: 'secret',\n required: true,\n helpText:\n 'Developer Portal \u2192 Applications \u2192 your app \u2192 Bot \u2192 Reset Token. Grants full control of the bot user. Stored encrypted at rest; never logged.',\n },\n {\n key: 'applicationId',\n label: 'Application ID',\n type: 'text',\n required: true,\n placeholder: '123456789012345678',\n helpText:\n 'Developer Portal \u2192 General Information \u2192 Application ID. Used to register slash commands'\n + ' (mercato channel_discord register-slash-commands) and to answer interactions.',\n },\n {\n key: 'publicKey',\n label: 'Public key',\n type: 'text',\n required: true,\n placeholder: '64-character hex Ed25519 public key',\n helpText:\n 'Developer Portal \u2192 General Information \u2192 Public Key. Verifies signed interaction requests; a verified'\n + ' slash command or button press is recorded in the inbox and answered back in Discord.',\n },\n {\n key: 'guildId',\n label: 'Guild (server) ID',\n type: 'text',\n required: false,\n placeholder: '123456789012345678',\n helpText: 'Recommended: scope the bot to a single server. Enable Developer Mode in Discord, right-click the server, Copy Server ID.',\n },\n {\n key: 'defaultChannelId',\n label: 'Default channel ID',\n type: 'text',\n required: false,\n placeholder: '123456789012345678',\n helpText:\n 'Default text channel the adapter posts to when an outbound message names no Discord channel. To verify it, call the'\n + ' hub test-send endpoint with an empty JSON body ({}) so no recipient is named, or pass a channel snowflake as the'\n + ' recipient to post somewhere else.',\n },\n ],\n },\n healthCheck: { service: 'channelDiscordHealthCheck' },\n}\n\nexport const integrations: IntegrationDefinition[] = [integration]\nexport const bundles: IntegrationBundle[] = []\nexport const bundle: IntegrationBundle | undefined = undefined\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,0CAA8F;AAEhG,MAAM,mCAAmC,mCAAmC,iBAAiB;AAE7F,MAAM,cAAqC;AAAA,EAChD,IAAI;AAAA,EACJ,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMP,aAAa;AAAA,EACb,UAAU;AAAA,EACV,KAAK;AAAA,EACL,aAAa;AAAA,EACb,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA;AAAA;AAAA;AAAA,EAIT,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,SAAS;AAAA;AAAA;AAAA;AAAA,EAIT,MAAM,CAAC,WAAW,QAAQ,OAAO,WAAW,iBAAiB,IAAI;AAAA,EACjE,YAAY;AAAA,IACV,cAAc;AAAA,EAChB;AAAA,EACA,aAAa;AAAA,IACX;AAAA,MACE,IAAI;AAAA,MACJ,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,SAAS;AAAA,MACT,WAAW;AAAA,IACb;AAAA,EACF;AAAA,EACA,aAAa;AAAA,IACX,QAAQ;AAAA,MACN;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,MACJ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UACE;AAAA,MAEJ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UACE;AAAA,MAEJ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UACE;AAAA,MAGJ;AAAA,IACF;AAAA,EACF;AAAA,EACA,aAAa,EAAE,SAAS,4BAA4B;AACtD;AAEO,MAAM,eAAwC,CAAC,WAAW;AAC1D,MAAM,UAA+B,CAAC;AACtC,MAAM,SAAwC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -38,7 +38,7 @@ class DiscordChannelAdapter {
|
|
|
38
38
|
return {
|
|
39
39
|
externalMessageId: "",
|
|
40
40
|
status: "failed",
|
|
41
|
-
error:
|
|
41
|
+
error: 'No Discord channel to post to \u2014 set "Default channel ID" on the connection, or pass a channel id as the recipient.'
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
const meta = native.metadata ?? {};
|
|
@@ -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 { 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'\n readonly channelType = 'discord'\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 return {\n externalMessageId: '',\n status: 'failed',\n error: '[internal] Discord send requires a target channel id (conversationId or defaultChannelId)',\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,SAAS,gCAAgC,gCAAgC;AACzE;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AACP,SAAS,iCAAiC;AAC1C;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAqBP,MAAM,sBAAgD;AAAA,EAAtD;AACE,SAAS,cAAc;AACvB,SAAS,cAAc;AACvB,SAAS,eAAe;AAAA;AAAA,EAExB,MAAM,YAAY,OAAqD;AACrE,UAAM,cAAc,+BAA+B,MAAM,WAAW;AAEpE,QAAI;AACJ,QAAI;AACF,eAAS,MAAM,0BAA0B;AAAA,QACvC,MAAM,MAAM,QAAQ,QAAQ,MAAM,QAAQ,QAAQ;AAAA,QAClD,YAAY,MAAM,QAAQ,eAAe,MAAM,QAAQ,OAAO,SAAS;AAAA,QACvE,aAAa,MAAM,QAAQ;AAAA,QAC3B,iBAAiB,MAAM;AAAA,MACzB,CAAC;AAAA,IACH,SAAS,OAAO;AACd,YAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AACzD,aAAO,EAAE,mBAAmB,IAAI,QAAQ,UAAU,OAAO,QAAQ;AAAA,IACnE;AAEA,UAAM,kBAAkB,uBAAuB,OAAO,YAAY,gBAAgB;AAClF,QAAI,CAAC,iBAAiB;
|
|
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 { 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'\n readonly channelType = 'discord'\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": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_discord/lib/capabilities.ts"],
|
|
4
|
-
"sourcesContent": ["import type { ChannelCapabilities } from '@open-mercato/core/modules/communication_channels/lib/adapter'\n\n/**\n * Discord message content hard limit \u2014 the REST API rejects `content` longer\n * than 2000 characters (embeds have their own separate budget we don't use here).\n */\nexport const DISCORD_MAX_BODY_LENGTH = 2000\n\n/**\n * Discord capability profile (SPEC 2026-06-19 \u00A7 Adapter method map).\n *\n * The hub treats these flags as a contract: it routes work to the adapter based\n * on them, so each one describes what THIS adapter implements today, not what\n * the Discord API is able to do. Everything the first release does not implement\n * is declared `false` and stays `false` until the corresponding code lands.\n *\n * Enabled, with the implementation behind each:\n * - `richText` / `supportedBodyFormats: ['text', 'markdown']` \u2014 Discord content\n * is markdown-native; HTML is down-converted in `convertOutbound`.\n * - `reactions` / `editMessage` / `deleteMessage` \u2014 backed by the matching\n * `discord-rest` calls the adapter exposes.\n * - `conversationHistory` \u2014 `fetchHistory` pages `GET /channels/{id}/messages`.\n * - `realtimePush` \u2014 the provider owns a long-running Gateway WebSocket worker\n * that delivers `MESSAGE_CREATE` / reaction events in real time, so the hub's\n * polling scheduler skips this channel (no redundant `fetchHistory`).\n * - `interactiveComponents` \u2014 slash commands, buttons, select menus and modal\n * submissions round-trip for real: the signed Interactions endpoint answers\n * with a deferred acknowledgement AND hands the interaction to\n * `workers/discord-interactions.ts`, which normalizes it into the hub's\n * existing inbound queue under the matched channel's tenant scope and then\n * replaces the \"thinking\u2026\" placeholder over Discord's interaction-webhook\n * endpoints. Pinned by the parity test in `lib/__tests__/capabilities.test.ts`,\n * which drives the whole path rather than asserting the flag alone.\n
|
|
5
|
-
"mappings": "AAMO,MAAM,0BAA0B;
|
|
4
|
+
"sourcesContent": ["import type { ChannelCapabilities } from '@open-mercato/core/modules/communication_channels/lib/adapter'\n\n/**\n * Discord message content hard limit \u2014 the REST API rejects `content` longer\n * than 2000 characters (embeds have their own separate budget we don't use here).\n */\nexport const DISCORD_MAX_BODY_LENGTH = 2000\n\n/**\n * Discord capability profile (SPEC 2026-06-19 \u00A7 Adapter method map).\n *\n * The hub treats these flags as a contract: it routes work to the adapter based\n * on them, so each one describes what THIS adapter implements today, not what\n * the Discord API is able to do. Everything the first release does not implement\n * is declared `false` and stays `false` until the corresponding code lands.\n *\n * Enabled, with the implementation behind each:\n * - `richText` / `supportedBodyFormats: ['text', 'markdown']` \u2014 Discord content\n * is markdown-native; HTML is down-converted in `convertOutbound`.\n * - `reactions` / `editMessage` / `deleteMessage` \u2014 backed by the matching\n * `discord-rest` calls the adapter exposes.\n * - `conversationHistory` \u2014 `fetchHistory` pages `GET /channels/{id}/messages`.\n * - `realtimePush` \u2014 the provider owns a long-running Gateway WebSocket worker\n * that delivers `MESSAGE_CREATE` / reaction events in real time, so the hub's\n * polling scheduler skips this channel (no redundant `fetchHistory`).\n * - `interactiveComponents` \u2014 slash commands, buttons, select menus and modal\n * submissions round-trip for real: the signed Interactions endpoint answers\n * with a deferred acknowledgement AND hands the interaction to\n * `workers/discord-interactions.ts`, which normalizes it into the hub's\n * existing inbound queue under the matched channel's tenant scope and then\n * replaces the \"thinking\u2026\" placeholder over Discord's interaction-webhook\n * endpoints. Pinned by the parity test in `lib/__tests__/capabilities.test.ts`,\n * which drives the whole path rather than asserting the flag alone.\n * - `threading` \u2014 a reply composed with `parentMessageId` arrives in Discord\n * attached to the message it answers. The hub resolves the parent's Discord\n * snowflake in `communication_channels/lib/outbound-reply-ref.ts` and writes it\n * to `channelMetadata.replyToExternalId`; `convertOutbound` turns that into\n * `messageReferenceId`, and `discord-rest` sends it as `message_reference`.\n * This flag was `false` for exactly as long as that hub-side producer was\n * missing (#5541, found against a live bot) \u2014 the whole path, not the flag\n * alone, is pinned by `lib/__tests__/capabilities.test.ts`.\n *\n * Deliberately disabled until implemented (declaring them would make the hub\n * hand this adapter work it silently drops):\n * - `fileSharing` / `inlineImages` \u2014 `convertOutbound` drops\n * `input.content.attachments` and `discord-rest` has no multipart upload, so\n * outbound attachments never reach Discord. `maxFileSize` /\n * `supportedMimeTypes` are omitted for the same reason.\n * - `typingIndicators` \u2014 no `POST /channels/{id}/typing` call exists here.\n * - `presence` \u2014 the bot identifies without the `GUILD_PRESENCES` intent and no\n * presence dispatch is handled.\n * - `richBlocks` \u2014 outbound is plain markdown `content`; embeds are not built.\n * - `stickers` \u2014 no sticker is sent or normalized.\n *\n * AI auto-reply (issue #4778) deliberately flips NONE of these back to `true`.\n * `ChannelCapabilities` describes what the hub may hand this ADAPTER \u2014 message\n * shapes, transport features, whether to schedule polling. Auto-reply is not an\n * adapter capability at all: it is a subscriber on the hub's generic\n * `message.received` event that composes a reply through the same outbound path\n * any module uses. Adding a flag for it would tell the hub to route work here\n * that the adapter does not implement, which is exactly the failure mode this\n * list exists to prevent.\n */\nexport const discordCapabilities: ChannelCapabilities = {\n // A Discord recipient is a channel snowflake, never an address. Without this\n // the hub falls back to its `'email'` default and `validateOutboundRecipient`\n // rejects every real recipient with \"Recipient must be a valid email address\"\n // \u2014 which is #4976 still broken for the provider it was filed against, even\n // though #5261 built the mechanism to fix it. The hub then applies transport\n // safety only (allowlist + length), and this adapter owns the format and must\n // keep treating the value as untrusted.\n recipientFormat: 'provider-native',\n\n // Core\n threading: true,\n richText: true,\n fileSharing: false,\n readReceipts: false,\n deliveryReceipts: false,\n typingIndicators: false,\n\n // Extended\n reactions: true,\n multiReactionPerUser: false,\n editMessage: true,\n deleteMessage: true,\n presence: false,\n richBlocks: false,\n interactiveComponents: true,\n inlineImages: false,\n conversationHistory: true,\n contactCards: false,\n locationSharing: false,\n voiceNotes: false,\n stickers: false,\n\n // Content format support\n supportedBodyFormats: ['text', 'markdown'],\n maxBodyLength: DISCORD_MAX_BODY_LENGTH,\n\n // The gateway worker is the real-time source; the hub must not schedule polling.\n realtimePush: true,\n}\n"],
|
|
5
|
+
"mappings": "AAMO,MAAM,0BAA0B;AAyDhC,MAAM,sBAA2C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQtD,iBAAiB;AAAA;AAAA,EAGjB,WAAW;AAAA,EACX,UAAU;AAAA,EACV,aAAa;AAAA,EACb,cAAc;AAAA,EACd,kBAAkB;AAAA,EAClB,kBAAkB;AAAA;AAAA,EAGlB,WAAW;AAAA,EACX,sBAAsB;AAAA,EACtB,aAAa;AAAA,EACb,eAAe;AAAA,EACf,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,uBAAuB;AAAA,EACvB,cAAc;AAAA,EACd,qBAAqB;AAAA,EACrB,cAAc;AAAA,EACd,iBAAiB;AAAA,EACjB,YAAY;AAAA,EACZ,UAAU;AAAA;AAAA,EAGV,sBAAsB,CAAC,QAAQ,UAAU;AAAA,EACzC,eAAe;AAAA;AAAA,EAGf,cAAc;AAChB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -24,14 +24,17 @@ async function convertOutboundForDiscord(input) {
|
|
|
24
24
|
metadata: {
|
|
25
25
|
allowedMentions,
|
|
26
26
|
droppedAttachmentCount,
|
|
27
|
-
|
|
28
|
-
// into outbound metadata today, which is why `capabilities.threading` is
|
|
29
|
-
// declared `false`. Kept so that flag flips back with the hub-side
|
|
30
|
-
// producer and nothing else.
|
|
31
|
-
messageReferenceId: typeof input.channelMetadata?.replyToExternalId === "string" ? input.channelMetadata.replyToExternalId : void 0
|
|
27
|
+
messageReferenceId: resolveMessageReferenceId(input.channelMetadata)
|
|
32
28
|
}
|
|
33
29
|
};
|
|
34
30
|
}
|
|
31
|
+
function resolveMessageReferenceId(channelMetadata) {
|
|
32
|
+
const fromHub = channelMetadata?.replyToExternalId;
|
|
33
|
+
if (typeof fromHub === "string" && fromHub.length > 0) return fromHub;
|
|
34
|
+
const alreadyConverted = channelMetadata?.messageReferenceId;
|
|
35
|
+
if (typeof alreadyConverted === "string" && alreadyConverted.length > 0) return alreadyConverted;
|
|
36
|
+
return void 0;
|
|
37
|
+
}
|
|
35
38
|
export {
|
|
36
39
|
convertOutboundForDiscord
|
|
37
40
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_discord/lib/convert-outbound.ts"],
|
|
4
|
-
"sourcesContent": ["import type {\n ChannelNativeContent,\n ConvertOutboundInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { DISCORD_MAX_BODY_LENGTH } from './capabilities'\n\nconst logger = createLogger('channel_discord').child({ component: 'convert-outbound' })\n\n/**\n * Very small HTML \u2192 markdown down-converter for the common inline tags the hub's\n * `html` body format produces. Discord content is markdown-native, so we map the\n * handful of tags that have a markdown equivalent and strip the rest rather than\n * pulling in a full HTML parser dependency.\n */\nfunction htmlToMarkdown(html: string): string {\n return html\n .replace(/<\\s*br\\s*\\/?\\s*>/gi, '\\n')\n .replace(/<\\s*\\/\\s*p\\s*>/gi, '\\n\\n')\n .replace(/<\\s*p[^>]*>/gi, '')\n .replace(/<\\s*(strong|b)\\s*>(.*?)<\\s*\\/\\s*\\1\\s*>/gis, '**$2**')\n .replace(/<\\s*(em|i)\\s*>(.*?)<\\s*\\/\\s*\\1\\s*>/gis, '*$2*')\n .replace(/<\\s*code\\s*>(.*?)<\\s*\\/\\s*code\\s*>/gis, '`$1`')\n .replace(/<\\s*a[^>]*href=[\"']([^\"']+)[\"'][^>]*>(.*?)<\\s*\\/\\s*a\\s*>/gis, '[$2]($1)')\n .replace(/<[^>]+>/g, '')\n .replace(/ /gi, ' ')\n .replace(/&/gi, '&')\n .replace(/</gi, '<')\n .replace(/>/gi, '>')\n .replace(/"/gi, '\"')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n}\n\n/**\n * Convert the hub's normalized outbound body into Discord native content.\n *\n * - `markdown` / `text` pass through unchanged (Discord is markdown-native).\n * - `html` is down-converted to markdown.\n * - Content is clamped to Discord's 2000-char hard limit (a longer body is\n * truncated with an ellipsis marker rather than rejected by the API).\n * - `allowed_mentions` defaults to `{ parse: [] }` so an AI/automated reply can\n * never accidentally @-ping everyone; callers can widen it via `channelMetadata`.\n * - Attachments are NOT uploaded: `discord-rest` has no multipart upload, so the\n * capability profile declares `fileSharing: false` and the hub should never\n * route an attachment here. If one arrives anyway we log it rather than drop\n * it silently, and report the count in `metadata.droppedAttachmentCount` so the\n * caller can surface the gap.\n */\nexport async function convertOutboundForDiscord(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n const droppedAttachmentCount = input.attachments?.length ?? 0\n if (droppedAttachmentCount > 0) {\n logger.warn('discord outbound attachments are not supported \u2014 sending text only', {\n droppedAttachmentCount,\n })\n }\n\n const raw = input.body ?? ''\n const markdown = input.bodyFormat === 'html' ? htmlToMarkdown(raw) : raw\n\n const TRUNCATE_MARKER = '\u2026'\n const content =\n markdown.length > DISCORD_MAX_BODY_LENGTH\n ? markdown.slice(0, DISCORD_MAX_BODY_LENGTH - TRUNCATE_MARKER.length) + TRUNCATE_MARKER\n : markdown\n\n const allowedMentions =\n (input.channelMetadata?.allowedMentions as Record<string, unknown> | undefined) ?? { parse: [] }\n\n return {\n content: {\n text: content,\n bodyFormat: 'markdown',\n },\n metadata: {\n allowedMentions,\n droppedAttachmentCount,\n
|
|
5
|
-
"mappings": "AAIA,SAAS,oBAAoB;AAC7B,SAAS,+BAA+B;AAExC,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,mBAAmB,CAAC;AAQtF,SAAS,eAAe,MAAsB;AAC5C,SAAO,KACJ,QAAQ,sBAAsB,IAAI,EAClC,QAAQ,oBAAoB,MAAM,EAClC,QAAQ,iBAAiB,EAAE,EAC3B,QAAQ,6CAA6C,QAAQ,EAC7D,QAAQ,yCAAyC,MAAM,EACvD,QAAQ,yCAAyC,MAAM,EACvD,QAAQ,+DAA+D,UAAU,EACjF,QAAQ,YAAY,EAAE,EACtB,QAAQ,YAAY,GAAG,EACvB,QAAQ,WAAW,GAAG,EACtB,QAAQ,UAAU,GAAG,EACrB,QAAQ,UAAU,GAAG,EACrB,QAAQ,YAAY,GAAG,EACvB,QAAQ,WAAW,MAAM,EACzB,KAAK;AACV;AAiBA,eAAsB,0BAA0B,OAA4D;AAC1G,QAAM,yBAAyB,MAAM,aAAa,UAAU;AAC5D,MAAI,yBAAyB,GAAG;AAC9B,WAAO,KAAK,2EAAsE;AAAA,MAChF;AAAA,IACF,CAAC;AAAA,EACH;AAEA,QAAM,MAAM,MAAM,QAAQ;AAC1B,QAAM,WAAW,MAAM,eAAe,SAAS,eAAe,GAAG,IAAI;AAErE,QAAM,kBAAkB;AACxB,QAAM,UACJ,SAAS,SAAS,0BACd,SAAS,MAAM,GAAG,0BAA0B,gBAAgB,MAAM,IAAI,kBACtE;AAEN,QAAM,kBACH,MAAM,iBAAiB,mBAA2D,EAAE,OAAO,CAAC,EAAE;AAEjG,SAAO;AAAA,IACL,SAAS;AAAA,MACP,MAAM;AAAA,MACN,YAAY;AAAA,IACd;AAAA,IACA,UAAU;AAAA,MACR;AAAA,MACA;AAAA;AAAA;AAAA;
|
|
4
|
+
"sourcesContent": ["import type {\n ChannelNativeContent,\n ConvertOutboundInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { DISCORD_MAX_BODY_LENGTH } from './capabilities'\n\nconst logger = createLogger('channel_discord').child({ component: 'convert-outbound' })\n\n/**\n * Very small HTML \u2192 markdown down-converter for the common inline tags the hub's\n * `html` body format produces. Discord content is markdown-native, so we map the\n * handful of tags that have a markdown equivalent and strip the rest rather than\n * pulling in a full HTML parser dependency.\n */\nfunction htmlToMarkdown(html: string): string {\n return html\n .replace(/<\\s*br\\s*\\/?\\s*>/gi, '\\n')\n .replace(/<\\s*\\/\\s*p\\s*>/gi, '\\n\\n')\n .replace(/<\\s*p[^>]*>/gi, '')\n .replace(/<\\s*(strong|b)\\s*>(.*?)<\\s*\\/\\s*\\1\\s*>/gis, '**$2**')\n .replace(/<\\s*(em|i)\\s*>(.*?)<\\s*\\/\\s*\\1\\s*>/gis, '*$2*')\n .replace(/<\\s*code\\s*>(.*?)<\\s*\\/\\s*code\\s*>/gis, '`$1`')\n .replace(/<\\s*a[^>]*href=[\"']([^\"']+)[\"'][^>]*>(.*?)<\\s*\\/\\s*a\\s*>/gis, '[$2]($1)')\n .replace(/<[^>]+>/g, '')\n .replace(/ /gi, ' ')\n .replace(/&/gi, '&')\n .replace(/</gi, '<')\n .replace(/>/gi, '>')\n .replace(/"/gi, '\"')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n}\n\n/**\n * Convert the hub's normalized outbound body into Discord native content.\n *\n * - `markdown` / `text` pass through unchanged (Discord is markdown-native).\n * - `html` is down-converted to markdown.\n * - Content is clamped to Discord's 2000-char hard limit (a longer body is\n * truncated with an ellipsis marker rather than rejected by the API).\n * - `allowed_mentions` defaults to `{ parse: [] }` so an AI/automated reply can\n * never accidentally @-ping everyone; callers can widen it via `channelMetadata`.\n * - Attachments are NOT uploaded: `discord-rest` has no multipart upload, so the\n * capability profile declares `fileSharing: false` and the hub should never\n * route an attachment here. If one arrives anyway we log it rather than drop\n * it silently, and report the count in `metadata.droppedAttachmentCount` so the\n * caller can surface the gap.\n */\nexport async function convertOutboundForDiscord(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n const droppedAttachmentCount = input.attachments?.length ?? 0\n if (droppedAttachmentCount > 0) {\n logger.warn('discord outbound attachments are not supported \u2014 sending text only', {\n droppedAttachmentCount,\n })\n }\n\n const raw = input.body ?? ''\n const markdown = input.bodyFormat === 'html' ? htmlToMarkdown(raw) : raw\n\n const TRUNCATE_MARKER = '\u2026'\n const content =\n markdown.length > DISCORD_MAX_BODY_LENGTH\n ? markdown.slice(0, DISCORD_MAX_BODY_LENGTH - TRUNCATE_MARKER.length) + TRUNCATE_MARKER\n : markdown\n\n const allowedMentions =\n (input.channelMetadata?.allowedMentions as Record<string, unknown> | undefined) ?? { parse: [] }\n\n return {\n content: {\n text: content,\n bodyFormat: 'markdown',\n },\n metadata: {\n allowedMentions,\n droppedAttachmentCount,\n messageReferenceId: resolveMessageReferenceId(input.channelMetadata),\n },\n }\n}\n\n/**\n * Resolve the id of the message an outbound reply attaches to. Order of\n * precedence:\n * 1. `replyToExternalId` \u2014 what the hub writes on the outbound path\n * (`communication_channels/lib/outbound-reply-ref.ts`) when the message\n * being delivered answers one that already reached this channel.\n * 2. `messageReferenceId` \u2014 our own already-converted key, which survives the\n * hub's convert\u2192send double-conversion: `deliver-outbound-message` calls\n * `convertOutbound` and then hands `converted.metadata` to `sendMessage`,\n * which re-converts it. Without accepting the converted key on that second\n * pass the rename `replyToExternalId` \u2192 `messageReferenceId` is one-way and\n * the reference is silently dropped before it reaches Discord (#5541).\n * `channel-gmail/lib/convert-outbound.ts` keeps `threadId` stable across\n * the same seam for the same reason.\n *\n * Both are absent on a non-reply, which sends a plain channel message.\n */\nfunction resolveMessageReferenceId(\n channelMetadata: ConvertOutboundInput['channelMetadata'],\n): string | undefined {\n const fromHub = channelMetadata?.replyToExternalId\n if (typeof fromHub === 'string' && fromHub.length > 0) return fromHub\n const alreadyConverted = channelMetadata?.messageReferenceId\n if (typeof alreadyConverted === 'string' && alreadyConverted.length > 0) return alreadyConverted\n return undefined\n}\n"],
|
|
5
|
+
"mappings": "AAIA,SAAS,oBAAoB;AAC7B,SAAS,+BAA+B;AAExC,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,mBAAmB,CAAC;AAQtF,SAAS,eAAe,MAAsB;AAC5C,SAAO,KACJ,QAAQ,sBAAsB,IAAI,EAClC,QAAQ,oBAAoB,MAAM,EAClC,QAAQ,iBAAiB,EAAE,EAC3B,QAAQ,6CAA6C,QAAQ,EAC7D,QAAQ,yCAAyC,MAAM,EACvD,QAAQ,yCAAyC,MAAM,EACvD,QAAQ,+DAA+D,UAAU,EACjF,QAAQ,YAAY,EAAE,EACtB,QAAQ,YAAY,GAAG,EACvB,QAAQ,WAAW,GAAG,EACtB,QAAQ,UAAU,GAAG,EACrB,QAAQ,UAAU,GAAG,EACrB,QAAQ,YAAY,GAAG,EACvB,QAAQ,WAAW,MAAM,EACzB,KAAK;AACV;AAiBA,eAAsB,0BAA0B,OAA4D;AAC1G,QAAM,yBAAyB,MAAM,aAAa,UAAU;AAC5D,MAAI,yBAAyB,GAAG;AAC9B,WAAO,KAAK,2EAAsE;AAAA,MAChF;AAAA,IACF,CAAC;AAAA,EACH;AAEA,QAAM,MAAM,MAAM,QAAQ;AAC1B,QAAM,WAAW,MAAM,eAAe,SAAS,eAAe,GAAG,IAAI;AAErE,QAAM,kBAAkB;AACxB,QAAM,UACJ,SAAS,SAAS,0BACd,SAAS,MAAM,GAAG,0BAA0B,gBAAgB,MAAM,IAAI,kBACtE;AAEN,QAAM,kBACH,MAAM,iBAAiB,mBAA2D,EAAE,OAAO,CAAC,EAAE;AAEjG,SAAO;AAAA,IACL,SAAS;AAAA,MACP,MAAM;AAAA,MACN,YAAY;AAAA,IACd;AAAA,IACA,UAAU;AAAA,MACR;AAAA,MACA;AAAA,MACA,oBAAoB,0BAA0B,MAAM,eAAe;AAAA,IACrE;AAAA,EACF;AACF;AAmBA,SAAS,0BACP,iBACoB;AACpB,QAAM,UAAU,iBAAiB;AACjC,MAAI,OAAO,YAAY,YAAY,QAAQ,SAAS,EAAG,QAAO;AAC9D,QAAM,mBAAmB,iBAAiB;AAC1C,MAAI,OAAO,qBAAqB,YAAY,iBAAiB,SAAS,EAAG,QAAO;AAChF,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -27,7 +27,7 @@ class FetchDiscordRestClient {
|
|
|
27
27
|
allowed_mentions: input.allowedMentions ?? { parse: [] }
|
|
28
28
|
};
|
|
29
29
|
if (input.messageReferenceId) {
|
|
30
|
-
body.message_reference = { message_id: input.messageReferenceId };
|
|
30
|
+
body.message_reference = { message_id: input.messageReferenceId, fail_if_not_exists: false };
|
|
31
31
|
}
|
|
32
32
|
return this.request(
|
|
33
33
|
auth,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_discord/lib/discord-rest.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Thin Discord REST API client. Same trade-off as the Gmail provider's\n * `gmail-client.ts`: we call `fetch` directly (no `discord.js`) so the adapter\n * ships zero heavyweight runtime dependencies and stays swappable in tests via\n * `setDiscordRestClient(...)`.\n *\n * Only the endpoints the adapter actually uses are exposed:\n * - createMessage \u2192 POST /channels/{id}/messages\n * - editMessage \u2192 PATCH /channels/{id}/messages/{mid}\n * - deleteMessage \u2192 DELETE /channels/{id}/messages/{mid}\n * - addReaction \u2192 PUT /channels/{id}/messages/{mid}/reactions/{emoji}/@me\n * - removeReaction \u2192 DELETE /channels/{id}/messages/{mid}/reactions/{emoji}/@me\n * - listMessages \u2192 GET /channels/{id}/messages?before=\n * - getCurrentUser \u2192 GET /users/@me\n * - getGatewayBot \u2192 GET /gateway/bot\n * - registerGuildCommands \u2192 PUT /applications/{app}/guilds/{guild}/commands\n * - editOriginalInteractionResponse \u2192 PATCH /webhooks/{app}/{token}/messages/@original\n * - createInteractionFollowUp \u2192 POST /webhooks/{app}/{token}\n */\nimport { fetchWithTimeout, FetchTimeoutError } from '@open-mercato/shared/lib/http/fetchWithTimeout'\n\nexport const DISCORD_API_BASE = 'https://discord.com/api/v10'\n\nexport interface DiscordAuth {\n botToken: string\n}\n\nexport interface DiscordUser {\n id: string\n username: string\n global_name?: string | null\n bot?: boolean\n discriminator?: string\n avatar?: string | null\n}\n\n/**\n * Open Mercato marker carried by a message object that was SYNTHESIZED from a\n * Discord interaction rather than received from Discord. Discord never returns\n * this key; it exists so `normalizeInboundDiscordMessage` can tell a hub\n * consumer that the record came from a slash command or a component press\n * instead of someone typing in the channel.\n */\nexport interface DiscordInteractionOrigin {\n id: string\n type: number\n applicationId: string\n commandName?: string\n customId?: string\n}\n\nexport interface DiscordMessageObject {\n id: string\n channel_id: string\n guild_id?: string\n content: string\n author: DiscordUser\n timestamp: string\n attachments?: Array<{ id: string; url: string; filename: string; content_type?: string; size?: number }>\n message_reference?: { message_id?: string; channel_id?: string; guild_id?: string }\n /** Present only on interaction-derived messages \u2014 see `DiscordInteractionOrigin`. */\n discord_interaction?: DiscordInteractionOrigin\n [key: string]: unknown\n}\n\nexport interface CreateMessageInput {\n channelId: string\n content: string\n messageReferenceId?: string\n allowedMentions?: Record<string, unknown>\n}\n\n/**\n * Body of a message that replaces (or follows) a deferred interaction response.\n *\n * `ephemeral` maps to Discord's `flags: 64` \u2014 only the invoking user sees the\n * message. It is the right default for an operational acknowledgement that would\n * otherwise be noise for everyone else in the channel.\n */\nexport interface InteractionResponseInput {\n content: string\n ephemeral?: boolean\n}\n\n/** Discord message flag: visible only to the user who triggered the interaction. */\nexport const DISCORD_MESSAGE_FLAG_EPHEMERAL = 64\n\nexport interface GatewayBotResponse {\n url: string\n shards: number\n session_start_limit?: { total: number; remaining: number; reset_after: number; max_concurrency: number }\n}\n\nexport interface DiscordRestClient {\n createMessage(auth: DiscordAuth, input: CreateMessageInput): Promise<DiscordMessageObject>\n editMessage(auth: DiscordAuth, channelId: string, messageId: string, content: string): Promise<DiscordMessageObject>\n deleteMessage(auth: DiscordAuth, channelId: string, messageId: string): Promise<void>\n addReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void>\n removeReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void>\n listMessages(\n auth: DiscordAuth,\n channelId: string,\n input: { before?: string; limit?: number },\n ): Promise<DiscordMessageObject[]>\n getCurrentUser(auth: DiscordAuth): Promise<DiscordUser>\n getGatewayBot(auth: DiscordAuth): Promise<GatewayBotResponse>\n registerGuildCommands(\n auth: DiscordAuth,\n applicationId: string,\n guildId: string,\n commands: Array<Record<string, unknown>>,\n ): Promise<void>\n /**\n * Replace the \"thinking\u2026\" placeholder a deferred interaction response left\n * behind. This is the call that ends the pending state, so it is the one the\n * dispatch worker makes first.\n *\n * Takes NO bot token: the interaction token in the URL is the credential, and\n * Discord documents these webhook endpoints as requiring no authorization. The\n * token is single-interaction and expires after 15 minutes.\n */\n editOriginalInteractionResponse(\n applicationId: string,\n interactionToken: string,\n input: InteractionResponseInput,\n ): Promise<void>\n /**\n * Post an ADDITIONAL message against the same interaction token. Used as the\n * fallback when the original response can no longer be edited.\n */\n createInteractionFollowUp(\n applicationId: string,\n interactionToken: string,\n input: InteractionResponseInput,\n ): Promise<void>\n}\n\nexport class DiscordApiError extends Error {\n readonly status: number\n readonly detail: string\n constructor(message: string, status: number, detail: string) {\n super(message)\n this.name = 'DiscordApiError'\n this.status = status\n this.detail = detail\n }\n}\n\nconst DISCORD_MAX_RETRIES = 3\nconst DISCORD_BACKOFF_BASE_MS = 500\nconst DISCORD_BACKOFF_CAP_MS = 8_000\nconst DISCORD_DEFAULT_REQUEST_TIMEOUT_MS = 15_000\n\nfunction resolveRequestTimeoutMs(): number {\n const fromEnv = Number.parseInt(process.env.OM_CHANNEL_DISCORD_REQUEST_TIMEOUT_MS ?? '', 10)\n return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DISCORD_DEFAULT_REQUEST_TIMEOUT_MS\n}\n\n/**\n * Discord emoji path encoding: unicode emoji is URL-encoded as-is; a custom\n * emoji uses the `name:id` form. We pass through the caller's value and only\n * `encodeURIComponent` it so both forms survive.\n */\nfunction encodeEmoji(emoji: string): string {\n return encodeURIComponent(emoji)\n}\n\nclass FetchDiscordRestClient implements DiscordRestClient {\n async createMessage(auth: DiscordAuth, input: CreateMessageInput): Promise<DiscordMessageObject> {\n const body: Record<string, unknown> = {\n content: input.content,\n allowed_mentions: input.allowedMentions ?? { parse: [] },\n }\n if (input.messageReferenceId) {\n body.message_reference = { message_id: input.messageReferenceId }\n }\n return this.request<DiscordMessageObject>(\n auth,\n 'POST',\n `/channels/${encodeURIComponent(input.channelId)}/messages`,\n body,\n )\n }\n\n async editMessage(\n auth: DiscordAuth,\n channelId: string,\n messageId: string,\n content: string,\n ): Promise<DiscordMessageObject> {\n return this.request<DiscordMessageObject>(\n auth,\n 'PATCH',\n `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`,\n { content },\n )\n }\n\n async deleteMessage(auth: DiscordAuth, channelId: string, messageId: string): Promise<void> {\n await this.request<void>(\n auth,\n 'DELETE',\n `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`,\n )\n }\n\n async addReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void> {\n await this.request<void>(\n auth,\n 'PUT',\n `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/reactions/${encodeEmoji(emoji)}/@me`,\n )\n }\n\n async removeReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void> {\n await this.request<void>(\n auth,\n 'DELETE',\n `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/reactions/${encodeEmoji(emoji)}/@me`,\n )\n }\n\n async listMessages(\n auth: DiscordAuth,\n channelId: string,\n input: { before?: string; limit?: number },\n ): Promise<DiscordMessageObject[]> {\n const search = new URLSearchParams()\n if (input.before) search.set('before', input.before)\n search.set('limit', String(Math.min(Math.max(input.limit ?? 50, 1), 100)))\n const query = search.toString()\n return this.request<DiscordMessageObject[]>(\n auth,\n 'GET',\n `/channels/${encodeURIComponent(channelId)}/messages?${query}`,\n )\n }\n\n async getCurrentUser(auth: DiscordAuth): Promise<DiscordUser> {\n return this.request<DiscordUser>(auth, 'GET', '/users/@me')\n }\n\n async getGatewayBot(auth: DiscordAuth): Promise<GatewayBotResponse> {\n return this.request<GatewayBotResponse>(auth, 'GET', '/gateway/bot')\n }\n\n async registerGuildCommands(\n auth: DiscordAuth,\n applicationId: string,\n guildId: string,\n commands: Array<Record<string, unknown>>,\n ): Promise<void> {\n await this.request<unknown>(\n auth,\n 'PUT',\n `/applications/${encodeURIComponent(applicationId)}/guilds/${encodeURIComponent(guildId)}/commands`,\n commands,\n )\n }\n\n async editOriginalInteractionResponse(\n applicationId: string,\n interactionToken: string,\n input: InteractionResponseInput,\n ): Promise<void> {\n await this.request<unknown>(\n null,\n 'PATCH',\n `/webhooks/${encodeURIComponent(applicationId)}/${encodeURIComponent(interactionToken)}/messages/@original`,\n interactionResponseBody(input),\n )\n }\n\n async createInteractionFollowUp(\n applicationId: string,\n interactionToken: string,\n input: InteractionResponseInput,\n ): Promise<void> {\n await this.request<unknown>(\n null,\n 'POST',\n `/webhooks/${encodeURIComponent(applicationId)}/${encodeURIComponent(interactionToken)}`,\n interactionResponseBody(input),\n )\n }\n\n private async request<T>(\n auth: DiscordAuth | null,\n method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',\n path: string,\n body?: unknown,\n ): Promise<T> {\n const url = `${DISCORD_API_BASE}${path}`\n // `auth === null` is the interaction-webhook family: the token in the path is\n // the credential and Discord rejects nothing for the missing header, so\n // sending the bot token there would leak it for no benefit.\n const headers: Record<string, string> = auth ? { Authorization: `Bot ${auth.botToken}` } : {}\n let payload: BodyInit | undefined\n if (body !== undefined) {\n headers['Content-Type'] = 'application/json'\n payload = JSON.stringify(body)\n }\n\n let attempt = 0\n let lastError: DiscordApiError | null = null\n while (attempt <= DISCORD_MAX_RETRIES) {\n let res: Response\n try {\n res = await fetchWithTimeout(url, { method, headers, body: payload, timeoutMs: resolveRequestTimeoutMs() })\n } catch (err) {\n const errName = (err as { name?: unknown } | null)?.name\n const aborted = err instanceof FetchTimeoutError || errName === 'TimeoutError' || errName === 'AbortError'\n if (!aborted) throw err\n const timeoutError = new DiscordApiError(`Discord API ${method} ${path} timed out`, 599, 'request timed out')\n if (attempt === DISCORD_MAX_RETRIES) throw timeoutError\n lastError = timeoutError\n await sleep(computeBackoff(attempt))\n attempt += 1\n continue\n }\n\n const text = await res.text()\n if (res.ok) {\n if (!text) return undefined as unknown as T\n return JSON.parse(text) as T\n }\n\n const detail = parseErrorMessage(text) ?? `${res.status} ${res.statusText}`\n const apiError = new DiscordApiError(`Discord API ${method} ${path} failed: ${detail}`, res.status, detail)\n const transient = res.status === 429 || (res.status >= 500 && res.status < 600)\n if (!transient || attempt === DISCORD_MAX_RETRIES) {\n throw apiError\n }\n lastError = apiError\n const waitMs = parseRetryAfter(res.headers.get('retry-after')) ?? computeBackoff(attempt)\n await sleep(waitMs)\n attempt += 1\n }\n throw lastError ?? new DiscordApiError(`Discord API ${method} ${path} exhausted retries`, 599, 'retries exhausted')\n }\n}\n\nfunction interactionResponseBody(input: InteractionResponseInput): Record<string, unknown> {\n return {\n content: input.content,\n // Interaction replies quote whatever the user typed, so mentions inside it\n // must not become live pings \u2014 same guard `createMessage` applies.\n allowed_mentions: { parse: [] },\n ...(input.ephemeral ? { flags: DISCORD_MESSAGE_FLAG_EPHEMERAL } : {}),\n }\n}\n\nfunction parseRetryAfter(value: string | null): number | null {\n if (!value) return null\n const asNumber = Number(value)\n if (Number.isFinite(asNumber) && asNumber >= 0) {\n return Math.min(asNumber * 1000, DISCORD_BACKOFF_CAP_MS)\n }\n return null\n}\n\nfunction computeBackoff(attempt: number): number {\n const raw = DISCORD_BACKOFF_BASE_MS * Math.pow(2, attempt)\n const jitter = Math.floor(Math.random() * 100)\n return Math.min(raw + jitter, DISCORD_BACKOFF_CAP_MS)\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nfunction parseErrorMessage(text: string): string | null {\n if (!text) return null\n try {\n const parsed = JSON.parse(text) as { message?: string; error?: string }\n if (typeof parsed?.message === 'string') return parsed.message\n if (typeof parsed?.error === 'string') return parsed.error\n } catch {\n /* fall through */\n }\n return text.length > 200 ? text.slice(0, 200) : text\n}\n\nlet cachedClient: DiscordRestClient | null = null\n\nexport function getDiscordRestClient(): DiscordRestClient {\n if (!cachedClient) cachedClient = new FetchDiscordRestClient()\n return cachedClient\n}\n\nexport function setDiscordRestClient(client: DiscordRestClient | null): void {\n cachedClient = client\n}\n"],
|
|
5
|
-
"mappings": "AAmBA,SAAS,kBAAkB,yBAAyB;AAE7C,MAAM,mBAAmB;AAgEzB,MAAM,iCAAiC;AAoDvC,MAAM,wBAAwB,MAAM;AAAA,EAGzC,YAAY,SAAiB,QAAgB,QAAgB;AAC3D,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,SAAS;AACd,SAAK,SAAS;AAAA,EAChB;AACF;AAEA,MAAM,sBAAsB;AAC5B,MAAM,0BAA0B;AAChC,MAAM,yBAAyB;AAC/B,MAAM,qCAAqC;AAE3C,SAAS,0BAAkC;AACzC,QAAM,UAAU,OAAO,SAAS,QAAQ,IAAI,yCAAyC,IAAI,EAAE;AAC3F,SAAO,OAAO,SAAS,OAAO,KAAK,UAAU,IAAI,UAAU;AAC7D;AAOA,SAAS,YAAY,OAAuB;AAC1C,SAAO,mBAAmB,KAAK;AACjC;AAEA,MAAM,uBAAoD;AAAA,EACxD,MAAM,cAAc,MAAmB,OAA0D;AAC/F,UAAM,OAAgC;AAAA,MACpC,SAAS,MAAM;AAAA,MACf,kBAAkB,MAAM,mBAAmB,EAAE,OAAO,CAAC,EAAE;AAAA,IACzD;AACA,QAAI,MAAM,oBAAoB;
|
|
4
|
+
"sourcesContent": ["/**\n * Thin Discord REST API client. Same trade-off as the Gmail provider's\n * `gmail-client.ts`: we call `fetch` directly (no `discord.js`) so the adapter\n * ships zero heavyweight runtime dependencies and stays swappable in tests via\n * `setDiscordRestClient(...)`.\n *\n * Only the endpoints the adapter actually uses are exposed:\n * - createMessage \u2192 POST /channels/{id}/messages\n * - editMessage \u2192 PATCH /channels/{id}/messages/{mid}\n * - deleteMessage \u2192 DELETE /channels/{id}/messages/{mid}\n * - addReaction \u2192 PUT /channels/{id}/messages/{mid}/reactions/{emoji}/@me\n * - removeReaction \u2192 DELETE /channels/{id}/messages/{mid}/reactions/{emoji}/@me\n * - listMessages \u2192 GET /channels/{id}/messages?before=\n * - getCurrentUser \u2192 GET /users/@me\n * - getGatewayBot \u2192 GET /gateway/bot\n * - registerGuildCommands \u2192 PUT /applications/{app}/guilds/{guild}/commands\n * - editOriginalInteractionResponse \u2192 PATCH /webhooks/{app}/{token}/messages/@original\n * - createInteractionFollowUp \u2192 POST /webhooks/{app}/{token}\n */\nimport { fetchWithTimeout, FetchTimeoutError } from '@open-mercato/shared/lib/http/fetchWithTimeout'\n\nexport const DISCORD_API_BASE = 'https://discord.com/api/v10'\n\nexport interface DiscordAuth {\n botToken: string\n}\n\nexport interface DiscordUser {\n id: string\n username: string\n global_name?: string | null\n bot?: boolean\n discriminator?: string\n avatar?: string | null\n}\n\n/**\n * Open Mercato marker carried by a message object that was SYNTHESIZED from a\n * Discord interaction rather than received from Discord. Discord never returns\n * this key; it exists so `normalizeInboundDiscordMessage` can tell a hub\n * consumer that the record came from a slash command or a component press\n * instead of someone typing in the channel.\n */\nexport interface DiscordInteractionOrigin {\n id: string\n type: number\n applicationId: string\n commandName?: string\n customId?: string\n}\n\nexport interface DiscordMessageObject {\n id: string\n channel_id: string\n guild_id?: string\n content: string\n author: DiscordUser\n timestamp: string\n attachments?: Array<{ id: string; url: string; filename: string; content_type?: string; size?: number }>\n message_reference?: { message_id?: string; channel_id?: string; guild_id?: string }\n /** Present only on interaction-derived messages \u2014 see `DiscordInteractionOrigin`. */\n discord_interaction?: DiscordInteractionOrigin\n [key: string]: unknown\n}\n\nexport interface CreateMessageInput {\n channelId: string\n content: string\n messageReferenceId?: string\n allowedMentions?: Record<string, unknown>\n}\n\n/**\n * Body of a message that replaces (or follows) a deferred interaction response.\n *\n * `ephemeral` maps to Discord's `flags: 64` \u2014 only the invoking user sees the\n * message. It is the right default for an operational acknowledgement that would\n * otherwise be noise for everyone else in the channel.\n */\nexport interface InteractionResponseInput {\n content: string\n ephemeral?: boolean\n}\n\n/** Discord message flag: visible only to the user who triggered the interaction. */\nexport const DISCORD_MESSAGE_FLAG_EPHEMERAL = 64\n\nexport interface GatewayBotResponse {\n url: string\n shards: number\n session_start_limit?: { total: number; remaining: number; reset_after: number; max_concurrency: number }\n}\n\nexport interface DiscordRestClient {\n createMessage(auth: DiscordAuth, input: CreateMessageInput): Promise<DiscordMessageObject>\n editMessage(auth: DiscordAuth, channelId: string, messageId: string, content: string): Promise<DiscordMessageObject>\n deleteMessage(auth: DiscordAuth, channelId: string, messageId: string): Promise<void>\n addReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void>\n removeReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void>\n listMessages(\n auth: DiscordAuth,\n channelId: string,\n input: { before?: string; limit?: number },\n ): Promise<DiscordMessageObject[]>\n getCurrentUser(auth: DiscordAuth): Promise<DiscordUser>\n getGatewayBot(auth: DiscordAuth): Promise<GatewayBotResponse>\n registerGuildCommands(\n auth: DiscordAuth,\n applicationId: string,\n guildId: string,\n commands: Array<Record<string, unknown>>,\n ): Promise<void>\n /**\n * Replace the \"thinking\u2026\" placeholder a deferred interaction response left\n * behind. This is the call that ends the pending state, so it is the one the\n * dispatch worker makes first.\n *\n * Takes NO bot token: the interaction token in the URL is the credential, and\n * Discord documents these webhook endpoints as requiring no authorization. The\n * token is single-interaction and expires after 15 minutes.\n */\n editOriginalInteractionResponse(\n applicationId: string,\n interactionToken: string,\n input: InteractionResponseInput,\n ): Promise<void>\n /**\n * Post an ADDITIONAL message against the same interaction token. Used as the\n * fallback when the original response can no longer be edited.\n */\n createInteractionFollowUp(\n applicationId: string,\n interactionToken: string,\n input: InteractionResponseInput,\n ): Promise<void>\n}\n\nexport class DiscordApiError extends Error {\n readonly status: number\n readonly detail: string\n constructor(message: string, status: number, detail: string) {\n super(message)\n this.name = 'DiscordApiError'\n this.status = status\n this.detail = detail\n }\n}\n\nconst DISCORD_MAX_RETRIES = 3\nconst DISCORD_BACKOFF_BASE_MS = 500\nconst DISCORD_BACKOFF_CAP_MS = 8_000\nconst DISCORD_DEFAULT_REQUEST_TIMEOUT_MS = 15_000\n\nfunction resolveRequestTimeoutMs(): number {\n const fromEnv = Number.parseInt(process.env.OM_CHANNEL_DISCORD_REQUEST_TIMEOUT_MS ?? '', 10)\n return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DISCORD_DEFAULT_REQUEST_TIMEOUT_MS\n}\n\n/**\n * Discord emoji path encoding: unicode emoji is URL-encoded as-is; a custom\n * emoji uses the `name:id` form. We pass through the caller's value and only\n * `encodeURIComponent` it so both forms survive.\n */\nfunction encodeEmoji(emoji: string): string {\n return encodeURIComponent(emoji)\n}\n\nclass FetchDiscordRestClient implements DiscordRestClient {\n async createMessage(auth: DiscordAuth, input: CreateMessageInput): Promise<DiscordMessageObject> {\n const body: Record<string, unknown> = {\n content: input.content,\n allowed_mentions: input.allowedMentions ?? { parse: [] },\n }\n if (input.messageReferenceId) {\n // `fail_if_not_exists` defaults to `true` on Discord's side: a reference to\n // a message that has since been deleted makes the API reject the send\n // outright (a 400, which `request` classifies as non-transient, so the hub\n // marks the link `failed` and the reply is never delivered). Users delete\n // messages routinely and the AI producers reference the thread ROOT \u2014 the\n // oldest and likeliest-deleted message in a conversation. Opting out makes\n // Discord fall back to a plain channel message, the same degradation\n // `outbound-reply-ref.ts` already applies to the cases the hub can detect:\n // an unthreaded delivery always beats a failed one.\n body.message_reference = { message_id: input.messageReferenceId, fail_if_not_exists: false }\n }\n return this.request<DiscordMessageObject>(\n auth,\n 'POST',\n `/channels/${encodeURIComponent(input.channelId)}/messages`,\n body,\n )\n }\n\n async editMessage(\n auth: DiscordAuth,\n channelId: string,\n messageId: string,\n content: string,\n ): Promise<DiscordMessageObject> {\n return this.request<DiscordMessageObject>(\n auth,\n 'PATCH',\n `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`,\n { content },\n )\n }\n\n async deleteMessage(auth: DiscordAuth, channelId: string, messageId: string): Promise<void> {\n await this.request<void>(\n auth,\n 'DELETE',\n `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`,\n )\n }\n\n async addReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void> {\n await this.request<void>(\n auth,\n 'PUT',\n `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/reactions/${encodeEmoji(emoji)}/@me`,\n )\n }\n\n async removeReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void> {\n await this.request<void>(\n auth,\n 'DELETE',\n `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/reactions/${encodeEmoji(emoji)}/@me`,\n )\n }\n\n async listMessages(\n auth: DiscordAuth,\n channelId: string,\n input: { before?: string; limit?: number },\n ): Promise<DiscordMessageObject[]> {\n const search = new URLSearchParams()\n if (input.before) search.set('before', input.before)\n search.set('limit', String(Math.min(Math.max(input.limit ?? 50, 1), 100)))\n const query = search.toString()\n return this.request<DiscordMessageObject[]>(\n auth,\n 'GET',\n `/channels/${encodeURIComponent(channelId)}/messages?${query}`,\n )\n }\n\n async getCurrentUser(auth: DiscordAuth): Promise<DiscordUser> {\n return this.request<DiscordUser>(auth, 'GET', '/users/@me')\n }\n\n async getGatewayBot(auth: DiscordAuth): Promise<GatewayBotResponse> {\n return this.request<GatewayBotResponse>(auth, 'GET', '/gateway/bot')\n }\n\n async registerGuildCommands(\n auth: DiscordAuth,\n applicationId: string,\n guildId: string,\n commands: Array<Record<string, unknown>>,\n ): Promise<void> {\n await this.request<unknown>(\n auth,\n 'PUT',\n `/applications/${encodeURIComponent(applicationId)}/guilds/${encodeURIComponent(guildId)}/commands`,\n commands,\n )\n }\n\n async editOriginalInteractionResponse(\n applicationId: string,\n interactionToken: string,\n input: InteractionResponseInput,\n ): Promise<void> {\n await this.request<unknown>(\n null,\n 'PATCH',\n `/webhooks/${encodeURIComponent(applicationId)}/${encodeURIComponent(interactionToken)}/messages/@original`,\n interactionResponseBody(input),\n )\n }\n\n async createInteractionFollowUp(\n applicationId: string,\n interactionToken: string,\n input: InteractionResponseInput,\n ): Promise<void> {\n await this.request<unknown>(\n null,\n 'POST',\n `/webhooks/${encodeURIComponent(applicationId)}/${encodeURIComponent(interactionToken)}`,\n interactionResponseBody(input),\n )\n }\n\n private async request<T>(\n auth: DiscordAuth | null,\n method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',\n path: string,\n body?: unknown,\n ): Promise<T> {\n const url = `${DISCORD_API_BASE}${path}`\n // `auth === null` is the interaction-webhook family: the token in the path is\n // the credential and Discord rejects nothing for the missing header, so\n // sending the bot token there would leak it for no benefit.\n const headers: Record<string, string> = auth ? { Authorization: `Bot ${auth.botToken}` } : {}\n let payload: BodyInit | undefined\n if (body !== undefined) {\n headers['Content-Type'] = 'application/json'\n payload = JSON.stringify(body)\n }\n\n let attempt = 0\n let lastError: DiscordApiError | null = null\n while (attempt <= DISCORD_MAX_RETRIES) {\n let res: Response\n try {\n res = await fetchWithTimeout(url, { method, headers, body: payload, timeoutMs: resolveRequestTimeoutMs() })\n } catch (err) {\n const errName = (err as { name?: unknown } | null)?.name\n const aborted = err instanceof FetchTimeoutError || errName === 'TimeoutError' || errName === 'AbortError'\n if (!aborted) throw err\n const timeoutError = new DiscordApiError(`Discord API ${method} ${path} timed out`, 599, 'request timed out')\n if (attempt === DISCORD_MAX_RETRIES) throw timeoutError\n lastError = timeoutError\n await sleep(computeBackoff(attempt))\n attempt += 1\n continue\n }\n\n const text = await res.text()\n if (res.ok) {\n if (!text) return undefined as unknown as T\n return JSON.parse(text) as T\n }\n\n const detail = parseErrorMessage(text) ?? `${res.status} ${res.statusText}`\n const apiError = new DiscordApiError(`Discord API ${method} ${path} failed: ${detail}`, res.status, detail)\n const transient = res.status === 429 || (res.status >= 500 && res.status < 600)\n if (!transient || attempt === DISCORD_MAX_RETRIES) {\n throw apiError\n }\n lastError = apiError\n const waitMs = parseRetryAfter(res.headers.get('retry-after')) ?? computeBackoff(attempt)\n await sleep(waitMs)\n attempt += 1\n }\n throw lastError ?? new DiscordApiError(`Discord API ${method} ${path} exhausted retries`, 599, 'retries exhausted')\n }\n}\n\nfunction interactionResponseBody(input: InteractionResponseInput): Record<string, unknown> {\n return {\n content: input.content,\n // Interaction replies quote whatever the user typed, so mentions inside it\n // must not become live pings \u2014 same guard `createMessage` applies.\n allowed_mentions: { parse: [] },\n ...(input.ephemeral ? { flags: DISCORD_MESSAGE_FLAG_EPHEMERAL } : {}),\n }\n}\n\nfunction parseRetryAfter(value: string | null): number | null {\n if (!value) return null\n const asNumber = Number(value)\n if (Number.isFinite(asNumber) && asNumber >= 0) {\n return Math.min(asNumber * 1000, DISCORD_BACKOFF_CAP_MS)\n }\n return null\n}\n\nfunction computeBackoff(attempt: number): number {\n const raw = DISCORD_BACKOFF_BASE_MS * Math.pow(2, attempt)\n const jitter = Math.floor(Math.random() * 100)\n return Math.min(raw + jitter, DISCORD_BACKOFF_CAP_MS)\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nfunction parseErrorMessage(text: string): string | null {\n if (!text) return null\n try {\n const parsed = JSON.parse(text) as { message?: string; error?: string }\n if (typeof parsed?.message === 'string') return parsed.message\n if (typeof parsed?.error === 'string') return parsed.error\n } catch {\n /* fall through */\n }\n return text.length > 200 ? text.slice(0, 200) : text\n}\n\nlet cachedClient: DiscordRestClient | null = null\n\nexport function getDiscordRestClient(): DiscordRestClient {\n if (!cachedClient) cachedClient = new FetchDiscordRestClient()\n return cachedClient\n}\n\nexport function setDiscordRestClient(client: DiscordRestClient | null): void {\n cachedClient = client\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/channel-discord",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7120.1.a0154ac412",
|
|
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.7.1-develop.
|
|
66
|
-
"@open-mercato/queue": "0.7.1-develop.
|
|
67
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
65
|
+
"@open-mercato/core": "0.7.1-develop.7120.1.a0154ac412",
|
|
66
|
+
"@open-mercato/queue": "0.7.1-develop.7120.1.a0154ac412",
|
|
67
|
+
"@open-mercato/ui": "0.7.1-develop.7120.1.a0154ac412"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
71
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
72
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
71
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7120.1.a0154ac412",
|
|
72
|
+
"@open-mercato/shared": "0.7.1-develop.7120.1.a0154ac412",
|
|
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.7.1-develop.
|
|
83
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
82
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7120.1.a0154ac412",
|
|
83
|
+
"@open-mercato/shared": "0.7.1-develop.7120.1.a0154ac412",
|
|
84
84
|
"@types/jest": "^30.0.0",
|
|
85
85
|
"@types/react": "^19.2.17",
|
|
86
86
|
"@types/react-dom": "^19.2.3",
|
|
@@ -85,8 +85,9 @@ export const integration: IntegrationDefinition = {
|
|
|
85
85
|
required: false,
|
|
86
86
|
placeholder: '123456789012345678',
|
|
87
87
|
helpText:
|
|
88
|
-
'Default text channel the adapter posts to when an outbound message names no Discord channel.
|
|
89
|
-
+ ' test-send endpoint
|
|
88
|
+
'Default text channel the adapter posts to when an outbound message names no Discord channel. To verify it, call the'
|
|
89
|
+
+ ' hub test-send endpoint with an empty JSON body ({}) so no recipient is named, or pass a channel snowflake as the'
|
|
90
|
+
+ ' recipient to post somewhere else.',
|
|
90
91
|
},
|
|
91
92
|
],
|
|
92
93
|
},
|
|
@@ -91,6 +91,29 @@ describe('DiscordChannelAdapter.sendMessage', () => {
|
|
|
91
91
|
expect(captured.createMessage?.channelId).toBe('default-chan')
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
+
it('explains itself to the operator when nothing names a channel', async () => {
|
|
95
|
+
// Reachable from the product since #4976 made test-send's recipient
|
|
96
|
+
// optional: an operator smoke-tests a connection whose `Default channel ID`
|
|
97
|
+
// was left blank. The route hands `result.error` straight back as
|
|
98
|
+
// `providerError`, so this string is read by a person, not a log.
|
|
99
|
+
const { captured } = stubRest()
|
|
100
|
+
const adapter = getDiscordChannelAdapter()
|
|
101
|
+
|
|
102
|
+
const result = await adapter.sendMessage({
|
|
103
|
+
content: { text: 'x' },
|
|
104
|
+
credentials,
|
|
105
|
+
scope: { organizationId: 'o', tenantId: 't' },
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(result.status).toBe('failed')
|
|
109
|
+
expect(result.error).toBe(
|
|
110
|
+
'No Discord channel to post to — set "Default channel ID" on the connection, or pass a channel id as the recipient.',
|
|
111
|
+
)
|
|
112
|
+
expect(result.error).not.toContain('[internal]')
|
|
113
|
+
// Nothing is posted when there is no target.
|
|
114
|
+
expect(captured.createMessage).toBeUndefined()
|
|
115
|
+
})
|
|
116
|
+
|
|
94
117
|
it('posts to the recipient the hub resolved, instead of silently using the default', async () => {
|
|
95
118
|
// QA of #4391 against a live bot: the hub's test-send route puts the
|
|
96
119
|
// operator's recipient in `metadata.to`, nothing read it, and every send
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { generateKeyPairSync, sign as cryptoSign } from 'node:crypto'
|
|
2
|
+
import { getDiscordChannelAdapter } from '../adapter'
|
|
2
3
|
import { discordCapabilities, DISCORD_MAX_BODY_LENGTH } from '../capabilities'
|
|
3
4
|
import { getDiscordRestClient } from '../discord-rest'
|
|
4
5
|
import { convertOutboundForDiscord } from '../convert-outbound'
|
|
@@ -15,6 +16,28 @@ function makeSigner() {
|
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Run `send` with the REST transport as the ONLY seam and return the JSON bodies
|
|
21
|
+
* Discord would have received. Spying on `request` rather than swapping in a fake
|
|
22
|
+
* client keeps the real `createMessage` in the path, so the body under assertion
|
|
23
|
+
* is the one production builds.
|
|
24
|
+
*/
|
|
25
|
+
async function captureSentBodies(send: () => Promise<void>): Promise<Array<Record<string, unknown>>> {
|
|
26
|
+
const sentBodies: Array<Record<string, unknown>> = []
|
|
27
|
+
const request = jest
|
|
28
|
+
.spyOn(getDiscordRestClient() as unknown as { request: (...args: unknown[]) => Promise<unknown> }, 'request')
|
|
29
|
+
.mockImplementation(async (..._args: unknown[]) => {
|
|
30
|
+
sentBodies.push(_args[3] as Record<string, unknown>)
|
|
31
|
+
return { id: 'sent-1', channel_id: '999' }
|
|
32
|
+
})
|
|
33
|
+
try {
|
|
34
|
+
await send()
|
|
35
|
+
} finally {
|
|
36
|
+
request.mockRestore()
|
|
37
|
+
}
|
|
38
|
+
return sentBodies
|
|
39
|
+
}
|
|
40
|
+
|
|
18
41
|
/**
|
|
19
42
|
* The hub routes work to the adapter based on these flags, so a capability that
|
|
20
43
|
* nothing implements is not cosmetic — it makes the hub hand this provider work
|
|
@@ -56,15 +79,55 @@ describe('discordCapabilities honesty', () => {
|
|
|
56
79
|
expect(discordCapabilities.stickers).toBe(false)
|
|
57
80
|
})
|
|
58
81
|
|
|
59
|
-
it('
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
82
|
+
it('advertises threading, and a reply carries message_reference all the way to the REST body', async () => {
|
|
83
|
+
// #5541: this flag was `false` while the conversion below existed but was
|
|
84
|
+
// unreachable — `channelMetadata.replyToExternalId` lived only on the INBOUND
|
|
85
|
+
// shape, and the hub's outbound producers wrote the email-shaped `inReplyTo` /
|
|
86
|
+
// `references` instead. `communication_channels/lib/outbound-reply-ref.ts` is
|
|
87
|
+
// the producer that closed the gap, so the flag may claim threading again.
|
|
88
|
+
//
|
|
89
|
+
// Assert the whole path, not the flag, and drive it in the ORDER THE HUB
|
|
90
|
+
// DRIVES IT (`deliver-outbound-message.ts`): `convertOutbound`, then
|
|
91
|
+
// `sendMessage` fed with `converted.metadata`. That second call re-converts
|
|
92
|
+
// the already-converted metadata, and the first version of this PR lost the
|
|
93
|
+
// reference exactly there — a green test that skipped `sendMessage` and
|
|
94
|
+
// hand-wired `createMessage` reproduced the #5541 blind spot it was written
|
|
95
|
+
// to close. Keep `restClient.request` as the only seam so the real converter,
|
|
96
|
+
// the real adapter and the real `createMessage` all stay in the path.
|
|
97
|
+
expect(discordCapabilities.threading).toBe(true)
|
|
98
|
+
|
|
99
|
+
const converted = await convertOutboundForDiscord({
|
|
100
|
+
body: 'hello',
|
|
101
|
+
bodyFormat: 'text',
|
|
102
|
+
channelMetadata: { replyToExternalId: 'parent-snowflake' },
|
|
103
|
+
} as Parameters<typeof convertOutboundForDiscord>[0])
|
|
104
|
+
expect(converted.metadata?.messageReferenceId).toBe('parent-snowflake')
|
|
105
|
+
|
|
106
|
+
const sentBodies = await captureSentBodies(async () => {
|
|
107
|
+
const result = await getDiscordChannelAdapter().sendMessage({
|
|
108
|
+
conversationId: '999',
|
|
109
|
+
content: converted.content,
|
|
110
|
+
credentials: {
|
|
111
|
+
botToken: 'bot-token',
|
|
112
|
+
applicationId: 'app-1',
|
|
113
|
+
publicKey: 'a'.repeat(64),
|
|
114
|
+
defaultChannelId: '999',
|
|
115
|
+
},
|
|
116
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
117
|
+
// Exactly what `deliver-outbound-message.ts` hands the adapter.
|
|
118
|
+
metadata: converted.metadata,
|
|
119
|
+
})
|
|
120
|
+
expect(result.status).toBe('sent')
|
|
121
|
+
})
|
|
122
|
+
expect(sentBodies[0]?.message_reference).toEqual({
|
|
123
|
+
message_id: 'parent-snowflake',
|
|
124
|
+
// A deleted parent must degrade to a plain channel message, not fail the
|
|
125
|
+
// delivery: Discord defaults this to `true` and answers 400, which the hub
|
|
126
|
+
// classifies as non-transient and marks the link `failed`.
|
|
127
|
+
fail_if_not_exists: false,
|
|
128
|
+
})
|
|
67
129
|
|
|
130
|
+
// A non-reply still sends a plain channel message — no dangling reference.
|
|
68
131
|
const withoutReplyId = await convertOutboundForDiscord({
|
|
69
132
|
body: 'hello',
|
|
70
133
|
bodyFormat: 'text',
|
|
@@ -72,14 +135,21 @@ describe('discordCapabilities honesty', () => {
|
|
|
72
135
|
} as Parameters<typeof convertOutboundForDiscord>[0])
|
|
73
136
|
expect(withoutReplyId.metadata?.messageReferenceId).toBeUndefined()
|
|
74
137
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
138
|
+
const plainBodies = await captureSentBodies(async () => {
|
|
139
|
+
await getDiscordChannelAdapter().sendMessage({
|
|
140
|
+
conversationId: '999',
|
|
141
|
+
content: withoutReplyId.content,
|
|
142
|
+
credentials: {
|
|
143
|
+
botToken: 'bot-token',
|
|
144
|
+
applicationId: 'app-1',
|
|
145
|
+
publicKey: 'a'.repeat(64),
|
|
146
|
+
defaultChannelId: '999',
|
|
147
|
+
},
|
|
148
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
149
|
+
metadata: withoutReplyId.metadata,
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
expect(plainBodies[0]?.message_reference).toBeUndefined()
|
|
83
153
|
})
|
|
84
154
|
|
|
85
155
|
it('does not advertise rich blocks — outbound is plain markdown, no embeds are built', () => {
|
|
@@ -36,4 +36,44 @@ describe('convertOutboundForDiscord', () => {
|
|
|
36
36
|
})
|
|
37
37
|
expect(result.metadata?.messageReferenceId).toBe('msg-42')
|
|
38
38
|
})
|
|
39
|
+
|
|
40
|
+
it('keeps the reply reference through the hub convert→send double-conversion', async () => {
|
|
41
|
+
// `deliver-outbound-message.ts` converts once, then hands `converted.metadata`
|
|
42
|
+
// to `sendMessage`, which converts again. The second pass never sees the hub's
|
|
43
|
+
// `replyToExternalId` — only our own renamed `messageReferenceId` — so a
|
|
44
|
+
// one-way rename silently drops the reference before it reaches Discord
|
|
45
|
+
// (#5541). Feed the first pass's output back in and the id must survive.
|
|
46
|
+
const first = await convertOutboundForDiscord({
|
|
47
|
+
body: 'reply',
|
|
48
|
+
bodyFormat: 'text',
|
|
49
|
+
channelMetadata: { replyToExternalId: 'msg-42' },
|
|
50
|
+
})
|
|
51
|
+
const second = await convertOutboundForDiscord({
|
|
52
|
+
body: first.content.text ?? '',
|
|
53
|
+
bodyFormat: 'markdown',
|
|
54
|
+
channelMetadata: first.metadata,
|
|
55
|
+
})
|
|
56
|
+
expect(second.metadata?.messageReferenceId).toBe('msg-42')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('lets the hub-resolved reference outrank a stale already-converted one', async () => {
|
|
60
|
+
// Only reachable if a caller round-trips metadata from an earlier message,
|
|
61
|
+
// but the precedence has to be the safe one: `replyToExternalId` is resolved
|
|
62
|
+
// by the hub against the message actually being delivered.
|
|
63
|
+
const result = await convertOutboundForDiscord({
|
|
64
|
+
body: 'reply',
|
|
65
|
+
bodyFormat: 'text',
|
|
66
|
+
channelMetadata: { replyToExternalId: 'msg-42', messageReferenceId: 'stale-7' },
|
|
67
|
+
})
|
|
68
|
+
expect(result.metadata?.messageReferenceId).toBe('msg-42')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('leaves a non-reply without a reference', async () => {
|
|
72
|
+
const result = await convertOutboundForDiscord({
|
|
73
|
+
body: 'plain',
|
|
74
|
+
bodyFormat: 'text',
|
|
75
|
+
channelMetadata: { inReplyTo: 'header-threaded', references: ['header-threaded'] },
|
|
76
|
+
})
|
|
77
|
+
expect(result.metadata?.messageReferenceId).toBeUndefined()
|
|
78
|
+
})
|
|
39
79
|
})
|
|
@@ -80,10 +80,15 @@ class DiscordChannelAdapter implements ChannelAdapter {
|
|
|
80
80
|
|
|
81
81
|
const targetChannelId = resolveTargetChannelId(input, credentials.defaultChannelId)
|
|
82
82
|
if (!targetChannelId) {
|
|
83
|
+
// Operator-facing, not `[internal]`: since #4976 made the hub's test-send
|
|
84
|
+
// recipient optional, this is the response an operator gets for the first
|
|
85
|
+
// thing they are told to try — a smoke test on a connection whose
|
|
86
|
+
// `Default channel ID` was left blank. It surfaces verbatim as
|
|
87
|
+
// `providerError`, so it names the two ways out rather than the internals.
|
|
83
88
|
return {
|
|
84
89
|
externalMessageId: '',
|
|
85
90
|
status: 'failed',
|
|
86
|
-
error: '
|
|
91
|
+
error: 'No Discord channel to post to — set "Default channel ID" on the connection, or pass a channel id as the recipient.',
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
|
|
@@ -31,18 +31,17 @@ export const DISCORD_MAX_BODY_LENGTH = 2000
|
|
|
31
31
|
* replaces the "thinking…" placeholder over Discord's interaction-webhook
|
|
32
32
|
* endpoints. Pinned by the parity test in `lib/__tests__/capabilities.test.ts`,
|
|
33
33
|
* which drives the whole path rather than asserting the flag alone.
|
|
34
|
+
* - `threading` — a reply composed with `parentMessageId` arrives in Discord
|
|
35
|
+
* attached to the message it answers. The hub resolves the parent's Discord
|
|
36
|
+
* snowflake in `communication_channels/lib/outbound-reply-ref.ts` and writes it
|
|
37
|
+
* to `channelMetadata.replyToExternalId`; `convertOutbound` turns that into
|
|
38
|
+
* `messageReferenceId`, and `discord-rest` sends it as `message_reference`.
|
|
39
|
+
* This flag was `false` for exactly as long as that hub-side producer was
|
|
40
|
+
* missing (#5541, found against a live bot) — the whole path, not the flag
|
|
41
|
+
* alone, is pinned by `lib/__tests__/capabilities.test.ts`.
|
|
34
42
|
*
|
|
35
43
|
* Deliberately disabled until implemented (declaring them would make the hub
|
|
36
44
|
* 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
45
|
* - `fileSharing` / `inlineImages` — `convertOutbound` drops
|
|
47
46
|
* `input.content.attachments` and `discord-rest` has no multipart upload, so
|
|
48
47
|
* outbound attachments never reach Discord. `maxFileSize` /
|
|
@@ -73,7 +72,7 @@ export const discordCapabilities: ChannelCapabilities = {
|
|
|
73
72
|
recipientFormat: 'provider-native',
|
|
74
73
|
|
|
75
74
|
// Core
|
|
76
|
-
threading:
|
|
75
|
+
threading: true,
|
|
77
76
|
richText: true,
|
|
78
77
|
fileSharing: false,
|
|
79
78
|
readReceipts: false,
|
|
@@ -75,14 +75,34 @@ export async function convertOutboundForDiscord(input: ConvertOutboundInput): Pr
|
|
|
75
75
|
metadata: {
|
|
76
76
|
allowedMentions,
|
|
77
77
|
droppedAttachmentCount,
|
|
78
|
-
|
|
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,
|
|
78
|
+
messageReferenceId: resolveMessageReferenceId(input.channelMetadata),
|
|
86
79
|
},
|
|
87
80
|
}
|
|
88
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the id of the message an outbound reply attaches to. Order of
|
|
85
|
+
* precedence:
|
|
86
|
+
* 1. `replyToExternalId` — what the hub writes on the outbound path
|
|
87
|
+
* (`communication_channels/lib/outbound-reply-ref.ts`) when the message
|
|
88
|
+
* being delivered answers one that already reached this channel.
|
|
89
|
+
* 2. `messageReferenceId` — our own already-converted key, which survives the
|
|
90
|
+
* hub's convert→send double-conversion: `deliver-outbound-message` calls
|
|
91
|
+
* `convertOutbound` and then hands `converted.metadata` to `sendMessage`,
|
|
92
|
+
* which re-converts it. Without accepting the converted key on that second
|
|
93
|
+
* pass the rename `replyToExternalId` → `messageReferenceId` is one-way and
|
|
94
|
+
* the reference is silently dropped before it reaches Discord (#5541).
|
|
95
|
+
* `channel-gmail/lib/convert-outbound.ts` keeps `threadId` stable across
|
|
96
|
+
* the same seam for the same reason.
|
|
97
|
+
*
|
|
98
|
+
* Both are absent on a non-reply, which sends a plain channel message.
|
|
99
|
+
*/
|
|
100
|
+
function resolveMessageReferenceId(
|
|
101
|
+
channelMetadata: ConvertOutboundInput['channelMetadata'],
|
|
102
|
+
): string | undefined {
|
|
103
|
+
const fromHub = channelMetadata?.replyToExternalId
|
|
104
|
+
if (typeof fromHub === 'string' && fromHub.length > 0) return fromHub
|
|
105
|
+
const alreadyConverted = channelMetadata?.messageReferenceId
|
|
106
|
+
if (typeof alreadyConverted === 'string' && alreadyConverted.length > 0) return alreadyConverted
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
@@ -172,7 +172,16 @@ class FetchDiscordRestClient implements DiscordRestClient {
|
|
|
172
172
|
allowed_mentions: input.allowedMentions ?? { parse: [] },
|
|
173
173
|
}
|
|
174
174
|
if (input.messageReferenceId) {
|
|
175
|
-
|
|
175
|
+
// `fail_if_not_exists` defaults to `true` on Discord's side: a reference to
|
|
176
|
+
// a message that has since been deleted makes the API reject the send
|
|
177
|
+
// outright (a 400, which `request` classifies as non-transient, so the hub
|
|
178
|
+
// marks the link `failed` and the reply is never delivered). Users delete
|
|
179
|
+
// messages routinely and the AI producers reference the thread ROOT — the
|
|
180
|
+
// oldest and likeliest-deleted message in a conversation. Opting out makes
|
|
181
|
+
// Discord fall back to a plain channel message, the same degradation
|
|
182
|
+
// `outbound-reply-ref.ts` already applies to the cases the hub can detect:
|
|
183
|
+
// an unthreaded delivery always beats a failed one.
|
|
184
|
+
body.message_reference = { message_id: input.messageReferenceId, fail_if_not_exists: false }
|
|
176
185
|
}
|
|
177
186
|
return this.request<DiscordMessageObject>(
|
|
178
187
|
auth,
|