@open-mercato/channel-discord 0.7.1-develop.7113.1.9d83dca10c → 0.7.1-develop.7121.1.734c263bda
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/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/adapter.ts +6 -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
|
}
|
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.7121.1.734c263bda",
|
|
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.7121.1.734c263bda",
|
|
66
|
+
"@open-mercato/queue": "0.7.1-develop.7121.1.734c263bda",
|
|
67
|
+
"@open-mercato/ui": "0.7.1-develop.7121.1.734c263bda"
|
|
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.7121.1.734c263bda",
|
|
72
|
+
"@open-mercato/shared": "0.7.1-develop.7121.1.734c263bda",
|
|
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.7121.1.734c263bda",
|
|
83
|
+
"@open-mercato/shared": "0.7.1-develop.7121.1.734c263bda",
|
|
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
|
|
@@ -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
|
|