@open-mercato/core 0.7.1-develop.7112.1.d486e6f1b2 → 0.7.1-develop.7113.1.9d83dca10c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js +34 -2
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
- package/dist/modules/communication_channels/lib/outbound-reply-ref.js +49 -0
- package/dist/modules/communication_channels/lib/outbound-reply-ref.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/communication_channels/commands/deliver-outbound-message.ts +45 -1
- package/src/modules/communication_channels/lib/outbound-reply-ref.ts +138 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getOrCreateThreadToken
|
|
12
12
|
} from "../lib/thread-token.js";
|
|
13
13
|
import { stringOrUndefined, stripBrackets } from "../lib/email-mime.js";
|
|
14
|
+
import { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from "../lib/outbound-reply-ref.js";
|
|
14
15
|
import { isUniqueViolation } from "../lib/pg-errors.js";
|
|
15
16
|
import { Message } from "../../messages/data/entities.js";
|
|
16
17
|
import {
|
|
@@ -211,6 +212,29 @@ const deliverOutboundMessageCommand = {
|
|
|
211
212
|
} catch (tokenErr) {
|
|
212
213
|
moduleLogger.warn("thread token unavailable, proceeding without it", { err: tokenErr });
|
|
213
214
|
}
|
|
215
|
+
let replyToExternalId = null;
|
|
216
|
+
try {
|
|
217
|
+
replyToExternalId = await resolveOutboundReplyExternalId(em, {
|
|
218
|
+
parentMessageId: message.parentMessageId ?? null,
|
|
219
|
+
externalConversationId: mapping.externalConversationId,
|
|
220
|
+
capabilities: adapter.capabilities,
|
|
221
|
+
scope: {
|
|
222
|
+
tenantId: input.scope.tenantId,
|
|
223
|
+
organizationId: input.scope.organizationId ?? null
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
} catch (replyRefErr) {
|
|
227
|
+
moduleLogger.warn("outbound reply reference unavailable, sending unthreaded", {
|
|
228
|
+
err: replyRefErr
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {
|
|
232
|
+
moduleLogger.debug("reply parent has no external id on this channel, sending unthreaded", {
|
|
233
|
+
messageId: message.id,
|
|
234
|
+
parentMessageId: message.parentMessageId,
|
|
235
|
+
conversationId: mapping.externalConversationId
|
|
236
|
+
});
|
|
237
|
+
}
|
|
214
238
|
try {
|
|
215
239
|
const outboundPayload = link.channelPayload ?? {};
|
|
216
240
|
const outboundHtml = typeof outboundPayload.html === "string" ? outboundPayload.html : null;
|
|
@@ -240,9 +264,17 @@ const deliverOutboundMessageCommand = {
|
|
|
240
264
|
bodyFormat: outboundBodyFormat,
|
|
241
265
|
channelMetadata: {
|
|
242
266
|
thread_id: mapping.externalThreadRef,
|
|
243
|
-
|
|
267
|
+
// Reply targeting is hub-resolved only. `send-as-user` merges
|
|
268
|
+
// caller-supplied metadata onto the link, so every reply-targeting key
|
|
269
|
+
// is stripped off the stored metadata here rather than merely
|
|
270
|
+
// out-ranked below: merge order settles the contest only when the hub
|
|
271
|
+
// resolved a parent, and the uncontested case — no `parentMessageId`,
|
|
272
|
+
// or a parent that does not resolve — is exactly the one a caller
|
|
273
|
+
// controls.
|
|
274
|
+
...stripCallerReplyTargeting(baseMetadata),
|
|
244
275
|
references: mergedReferences,
|
|
245
|
-
...threadToken ? { omThreadToken: threadToken } : {}
|
|
276
|
+
...threadToken ? { omThreadToken: threadToken } : {},
|
|
277
|
+
...replyToExternalId ? { replyToExternalId } : {}
|
|
246
278
|
}
|
|
247
279
|
});
|
|
248
280
|
const sendResult = await adapter.sendMessage({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/commands/deliver-outbound-message.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError, isReauthError } from '../lib/error-classification'\nimport {\n buildBodyFooter,\n buildReferencesId,\n getOrCreateThreadToken,\n} from '../lib/thread-token'\nimport { stringOrUndefined, stripBrackets } from '../lib/email-mime'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { Message } from '../../messages/data/entities'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst moduleLogger = createLogger('communication_channels').child({ component: 'deliver-outbound-message' })\n\n/**\n * Sentinel \u2014 `Message.threadId` of an internal-only (no channel link) message\n * has no matching `ChannelThreadMapping`. In that case outbound delivery is a no-op.\n */\nconst NO_THREAD_MAPPING_RESULT = { status: 'no_channel_link' as const }\n\nconst deliverInputSchema = z.object({\n messageId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n /**\n * If true, force a credential refresh before sending \u2014 used by retry attempts\n * after a 401 from the provider.\n */\n forceCredentialRefresh: z.boolean().optional(),\n})\n\nexport type DeliverOutboundMessageInput = z.infer<typeof deliverInputSchema>\n\nexport type DeliverOutboundMessageResult =\n | { status: 'no_channel_link' }\n | { status: 'already_delivered'; messageId: string; channelLinkId: string }\n | {\n status: 'delivered'\n messageId: string\n channelLinkId: string\n externalMessageId: string\n providerKey: string\n }\n | {\n status: 'failed'\n messageId: string\n channelLinkId: string\n providerKey: string\n error: string\n transient: boolean\n /**\n * True when the failure was a 401 / invalid_grant \u2014 the channel was\n * flipped to `requires_reauth`. The worker uses this to attempt one\n * forced credential refresh before giving up.\n */\n requiresReauth: boolean\n }\n\nexport const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =\n 'communication_channels.message.deliver_outbound'\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\ntype IntegrationLogLike = {\n log?: (entry: Record<string, unknown>) => Promise<void> | void\n warn?: (entry: Record<string, unknown>) => Promise<void> | void\n error?: (entry: Record<string, unknown>) => Promise<void> | void\n}\n\n/**\n * Outbound delivery command. Called from the outbound worker.\n *\n * Steps (SPEC-045d \u00A77):\n * 1. Re-fetch the Message by ID. Bail if internal-only (no ChannelThreadMapping).\n * 2. Resolve channel + adapter + credentials.\n * 3. Idempotently upsert a 'pending' MessageChannelLink (unique on `messageId`).\n * Skip if a 'sent'/'delivered' link already exists.\n * 4. Refresh credentials when OAuth + near expiry (or when caller forces it).\n * 5. Call `adapter.convertOutbound(...)` \u2192 channel-native content.\n * 6. Call `adapter.sendMessage(...)`.\n * 7. On success: persist ExternalMessage + flip link to 'sent', emit `.message.sent`.\n * 8. On failure: flip link to 'failed' + classify error, log to integrationLog,\n * emit `.delivery_failed`. The worker decides whether to retry based on\n * `result.transient`.\n *\n * Idempotency: the unique constraint on `message_channel_links.message_id`\n * prevents the same Message being sent twice through the channel even if the\n * subscriber fires repeatedly. Combined with the link's lifecycle state\n * (pending \u2192 sent | failed), we get safe retries.\n */\nconst deliverOutboundMessageCommand: CommandHandler<\n DeliverOutboundMessageInput,\n DeliverOutboundMessageResult\n> = {\n id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = deliverInputSchema.parse(rawInput) as DeliverOutboundMessageInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Re-fetch Message by ID \u2014 never trust the event payload shape.\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!message) {\n // Message was deleted before we got to deliver. Treat as no-op.\n return NO_THREAD_MAPPING_RESULT\n }\n if (!message.threadId) {\n // Message has no thread \u2192 no channel routing.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (1 cont.) Look up the channel link via ChannelThreadMapping.threadId.\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n messageThreadId: message.threadId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (!mapping) {\n // Internal-only message \u2014 no channel delivery needed.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (2) Channel + adapter.\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: mapping.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${mapping.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${mapping.channelId} is inactive; refusing to deliver outbound`)\n }\n\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(channel.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (3) Idempotently upsert a 'pending' MessageChannelLink.\n let link = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (\n link &&\n (link.deliveryStatus === 'queued' ||\n link.deliveryStatus === 'sent' ||\n link.deliveryStatus === 'delivered' ||\n link.deliveryStatus === 'read')\n ) {\n // Already sent (or accepted by the provider as 'queued') \u2014 short-circuit\n // so a retried job does not re-invoke the adapter and double-send.\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: link.id,\n }\n }\n if (!link) {\n link = em.create(MessageChannelLink, {\n messageId: message.id,\n externalConversationId: mapping.externalConversationId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n deliveryStatus: 'pending',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(link)\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the link lookup above is not atomic with this\n // insert, so two deliveries of the same message (a replayed\n // `messages.message.sent`, or an overlapping worker retry) can both\n // reach here. The `message_channel_links_message_uq` index rejects the\n // loser with a 23505. Defer to the winning job \u2014 re-read its link on a\n // fresh fork and report `already_delivered` \u2014 instead of re-invoking the\n // adapter (double send) or letting the raw error dead-letter the job.\n if (isUniqueViolation(flushErr)) {\n const winner = await findOneWithDecryption(\n em.fork(),\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (winner) {\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: winner.id,\n }\n }\n }\n throw flushErr\n }\n }\n\n // (2 cont.) Decrypted credentials via the integrations module (if available).\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve(\n 'integrationCredentialsService',\n ) as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected last. See\n // review R2-C1 / N1 (2026-05-26).\n //\n // Key the org on the CHANNEL's own org, not the message's org. Tenant-wide\n // channels store `organization_id = NULL` and their credentials live at\n // `organization_id = tenantId` (see connect-credential-channel.ts), so\n // `channel.organizationId ?? tenantId` matches the write key for both\n // org-scoped and tenant-wide channels. Keying on the message org would\n // resolve `{}` the moment a tenant-wide provider delivers from a message\n // that carries a non-null org.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n\n // (4) Credential refresh if OAuth + near expiry, or forced by retry.\n let integrationLog: IntegrationLogLike | null = null\n try {\n integrationLog = ctx.container.resolve('integrationLogService') as IntegrationLogLike\n } catch {\n integrationLog = null\n }\n const refreshResult = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n force: Boolean(input.forceCredentialRefresh),\n },\n {\n credentialsService,\n logger: (...args) => moduleLogger.warn('credential refresh diagnostic', { details: args }),\n },\n )\n credentials = refreshResult.credentials\n\n // (4b) Spec B \u2014 get-or-create the per-thread crypto token and inject it\n // into the outbound payload BEFORE the adapter converts it. The token\n // travels as both a `References` header (via channelMetadata.references)\n // and a hidden body marker so inbound replies can be threaded back to\n // this conversation even when the recipient's MUA strips RFC 5322\n // headers. Idempotent on retry \u2014 same token reused per thread.\n let threadToken: string | null = null\n try {\n const { token } = await getOrCreateThreadToken(em, {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageThreadId: mapping.messageThreadId,\n })\n threadToken = token\n } catch (tokenErr) {\n // Token creation should never block a send \u2014 if it fails, the message\n // still goes out, just without our thread-token attachment point.\n // Threading falls back to the JWZ strategy via Message-Id headers.\n moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })\n }\n\n // (5) + (6) Convert + send.\n try {\n const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}\n const outboundHtml = typeof outboundPayload.html === 'string' ? outboundPayload.html : null\n const outboundText = typeof outboundPayload.text === 'string' ? outboundPayload.text : null\n let outboundBody = outboundHtml ?? outboundText ?? message.body ?? ''\n const outboundBodyFormat = outboundHtml\n ? 'html'\n : ((message.bodyFormat as 'text' | 'markdown' | 'html') ?? 'text')\n\n // Pre-existing channelMetadata.references (string[]) so we can extend\n // it with the synthetic thread-token id without disturbing other refs\n // (e.g. the recipient's own reply chain).\n const baseMetadata = (link.channelMetadata as Record<string, unknown> | undefined) ?? {}\n const existingRefs = Array.isArray(baseMetadata.references)\n ? (baseMetadata.references as unknown[]).filter(\n (value): value is string => typeof value === 'string',\n )\n : []\n let mergedReferences = existingRefs\n if (threadToken && !outboundBody.includes(`[OM:${threadToken}]`)) {\n // Append the body footer for the corresponding format. The hidden\n // HTML span is `display:none`; the plain-text trailer is a small\n // bracketed marker on its own line. Both survive most reply clients.\n const footer = buildBodyFooter(threadToken)\n if (outboundBodyFormat === 'html') {\n const closingBody = outboundBody.lastIndexOf('</body>')\n outboundBody =\n closingBody >= 0\n ? `${outboundBody.slice(0, closingBody)}${footer.html}${outboundBody.slice(closingBody)}`\n : `${outboundBody}${footer.html}`\n } else {\n outboundBody = `${outboundBody}${footer.plain}`\n }\n const refId = buildReferencesId(threadToken)\n if (!mergedReferences.includes(refId)) {\n mergedReferences = [...mergedReferences, refId]\n }\n }\n const converted = await adapter.convertOutbound({\n body: outboundBody,\n bodyFormat: outboundBodyFormat,\n channelMetadata: {\n thread_id: mapping.externalThreadRef,\n ...baseMetadata,\n references: mergedReferences,\n ...(threadToken ? { omThreadToken: threadToken } : {}),\n },\n })\n\n // NOTE \u2014 at-least-once delivery (accepted v1 semantics). This provider\n // send is a non-transactional external side effect. The terminal-status\n // short-circuit above and the `message_channel_links_message_uq` index\n // prevent duplicate link records and re-sends after a *completed*\n // delivery, but if the process crashes in the narrow window between this\n // call returning and the success flush below, the link stays `pending`\n // and a worker retry re-invokes the adapter \u2014 the recipient may receive a\n // duplicate. This is deliberate: email providers (Gmail/SMTP) expose no\n // idempotent-send key nor a reliable \"did message X send?\" query, so\n // re-sending is preferred over risking a dropped message.\n const sendResult = await adapter.sendMessage({\n conversationId: mapping.externalThreadRef,\n content: converted.content,\n credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n },\n metadata: converted.metadata,\n })\n\n if (sendResult.status === 'failed') {\n throw new Error(sendResult.error ?? `Adapter '${adapter.providerKey}' reported send failure`)\n }\n\n // (7) Persist success records.\n //\n // Pre-generate the ExternalMessage PK client-side. The PK uses\n // `defaultRaw: 'gen_random_uuid()'`, so `externalMessage.id` is undefined\n // until after the INSERT returns \u2014 writing `link.externalMessageId =\n // externalMessage.id` before the flush would silently persist NULL on the\n // link's FK. Mirrors the inbound ingest path (ingest-inbound-message.ts).\n const externalMessageRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: channel.id,\n conversationId: mapping.externalConversationId,\n externalMessageId: sendResult.externalMessageId,\n direction: 'outbound',\n senderIdentifier: null,\n senderDisplayName: null,\n providerTimestamp: new Date(),\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // A successful send proves the credentials are valid \u2014 clear any prior\n // `requires_reauth` / `error` state so a recovered channel doesn't keep\n // showing a stale reconnect banner (e.g. after a forced-refresh retry).\n if (channel.status === 'requires_reauth' || channel.status === 'error') {\n channel.status = 'connected'\n }\n\n link.deliveryStatus = sendResult.status === 'sent' ? 'sent' : 'queued'\n link.externalMessageId = externalMessageRowId\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n ...(converted.metadata ?? {}),\n externalMessageId: sendResult.externalMessageId,\n // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ\n // strategy in lib/thread-matcher) and sent-folder dedup can resolve this\n // outbound message. Adapters that build the message themselves (Gmail)\n // return it in `converted.metadata.messageId`; IMAP/SMTP lets\n // the transport mint it, surfacing it only as `sendResult.externalMessageId`\n // (the RFC2822 id the recipient replies to) \u2014 fall back to that.\n // Store it bracket-stripped to match the inbound convention\n // (`normalizeMimeInbound` strips), so the JWZ matcher and sent-folder dedup \u2014\n // which compare against stripped ids \u2014 resolve it. `assembleRfc2822`\n // re-applies brackets when this id is later used to build reply headers.\n messageId: stripBrackets(\n stringOrUndefined((converted.metadata as Record<string, unknown> | undefined)?.messageId) ??\n sendResult.externalMessageId,\n ),\n }\n await em.flush()\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.sent',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'delivered',\n messageId: message.id,\n channelLinkId: link.id,\n externalMessageId: externalMessage.id,\n providerKey: channel.providerKey,\n }\n } catch (sendErr) {\n // (8) Failure path \u2014 classify, persist, emit, return.\n const classification = classifyOutboundError(sendErr)\n const requiresReauth = isReauthError(classification)\n link.deliveryStatus = 'failed'\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n lastError: classification.message,\n lastErrorAt: new Date().toISOString(),\n transient: classification.transient,\n requiresReauth,\n }\n // A 401 / invalid_grant means the stored credentials are dead and no\n // retry will help \u2014 flip the channel to `requires_reauth` (mirrors the\n // inbound poll path) so the operator gets a reconnect signal instead of\n // silently-failing sends. A later successful send self-heals back to\n // `connected` (see the success path above).\n if (requiresReauth) {\n channel.status = 'requires_reauth'\n }\n await em.flush()\n\n try {\n await integrationLog?.error?.({\n integrationId: `channel_${channel.providerKey}`,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n channelId: channel.id,\n messageId: message.id,\n status: classification.status ?? null,\n transient: classification.transient,\n message: classification.message,\n })\n } catch {\n // best-effort logging\n }\n\n if (requiresReauth) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.delivery_failed',\n {\n messageId: message.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n transient: classification.transient,\n error: classification.message,\n status: classification.status ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'failed',\n messageId: message.id,\n channelLinkId: link.id,\n providerKey: channel.providerKey,\n error: classification.message,\n transient: classification.transient,\n requiresReauth,\n }\n }\n },\n}\n\nregisterCommand(deliverOutboundMessageCommand)\n\nexport default deliverOutboundMessageCommand\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,SAAS;AAGlB,SAAS,uBAAuB;AAChC,SAAS,6BAA6B;AACtC,SAAS,sCAAsC;AAC/C,SAAS,kCAAkC;AAC3C,SAAS,uBAAuB,qBAAqB;AACrD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,mBAAmB,qBAAqB;
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError, isReauthError } from '../lib/error-classification'\nimport {\n buildBodyFooter,\n buildReferencesId,\n getOrCreateThreadToken,\n} from '../lib/thread-token'\nimport { stringOrUndefined, stripBrackets } from '../lib/email-mime'\nimport { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from '../lib/outbound-reply-ref'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { Message } from '../../messages/data/entities'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst moduleLogger = createLogger('communication_channels').child({ component: 'deliver-outbound-message' })\n\n/**\n * Sentinel \u2014 `Message.threadId` of an internal-only (no channel link) message\n * has no matching `ChannelThreadMapping`. In that case outbound delivery is a no-op.\n */\nconst NO_THREAD_MAPPING_RESULT = { status: 'no_channel_link' as const }\n\nconst deliverInputSchema = z.object({\n messageId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n /**\n * If true, force a credential refresh before sending \u2014 used by retry attempts\n * after a 401 from the provider.\n */\n forceCredentialRefresh: z.boolean().optional(),\n})\n\nexport type DeliverOutboundMessageInput = z.infer<typeof deliverInputSchema>\n\nexport type DeliverOutboundMessageResult =\n | { status: 'no_channel_link' }\n | { status: 'already_delivered'; messageId: string; channelLinkId: string }\n | {\n status: 'delivered'\n messageId: string\n channelLinkId: string\n externalMessageId: string\n providerKey: string\n }\n | {\n status: 'failed'\n messageId: string\n channelLinkId: string\n providerKey: string\n error: string\n transient: boolean\n /**\n * True when the failure was a 401 / invalid_grant \u2014 the channel was\n * flipped to `requires_reauth`. The worker uses this to attempt one\n * forced credential refresh before giving up.\n */\n requiresReauth: boolean\n }\n\nexport const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =\n 'communication_channels.message.deliver_outbound'\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\ntype IntegrationLogLike = {\n log?: (entry: Record<string, unknown>) => Promise<void> | void\n warn?: (entry: Record<string, unknown>) => Promise<void> | void\n error?: (entry: Record<string, unknown>) => Promise<void> | void\n}\n\n/**\n * Outbound delivery command. Called from the outbound worker.\n *\n * Steps (SPEC-045d \u00A77):\n * 1. Re-fetch the Message by ID. Bail if internal-only (no ChannelThreadMapping).\n * 2. Resolve channel + adapter + credentials.\n * 3. Idempotently upsert a 'pending' MessageChannelLink (unique on `messageId`).\n * Skip if a 'sent'/'delivered' link already exists.\n * 4. Refresh credentials when OAuth + near expiry (or when caller forces it).\n * 5. Call `adapter.convertOutbound(...)` \u2192 channel-native content.\n * 6. Call `adapter.sendMessage(...)`.\n * 7. On success: persist ExternalMessage + flip link to 'sent', emit `.message.sent`.\n * 8. On failure: flip link to 'failed' + classify error, log to integrationLog,\n * emit `.delivery_failed`. The worker decides whether to retry based on\n * `result.transient`.\n *\n * Idempotency: the unique constraint on `message_channel_links.message_id`\n * prevents the same Message being sent twice through the channel even if the\n * subscriber fires repeatedly. Combined with the link's lifecycle state\n * (pending \u2192 sent | failed), we get safe retries.\n */\nconst deliverOutboundMessageCommand: CommandHandler<\n DeliverOutboundMessageInput,\n DeliverOutboundMessageResult\n> = {\n id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = deliverInputSchema.parse(rawInput) as DeliverOutboundMessageInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Re-fetch Message by ID \u2014 never trust the event payload shape.\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!message) {\n // Message was deleted before we got to deliver. Treat as no-op.\n return NO_THREAD_MAPPING_RESULT\n }\n if (!message.threadId) {\n // Message has no thread \u2192 no channel routing.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (1 cont.) Look up the channel link via ChannelThreadMapping.threadId.\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n messageThreadId: message.threadId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (!mapping) {\n // Internal-only message \u2014 no channel delivery needed.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (2) Channel + adapter.\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: mapping.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${mapping.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${mapping.channelId} is inactive; refusing to deliver outbound`)\n }\n\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(channel.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (3) Idempotently upsert a 'pending' MessageChannelLink.\n let link = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (\n link &&\n (link.deliveryStatus === 'queued' ||\n link.deliveryStatus === 'sent' ||\n link.deliveryStatus === 'delivered' ||\n link.deliveryStatus === 'read')\n ) {\n // Already sent (or accepted by the provider as 'queued') \u2014 short-circuit\n // so a retried job does not re-invoke the adapter and double-send.\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: link.id,\n }\n }\n if (!link) {\n link = em.create(MessageChannelLink, {\n messageId: message.id,\n externalConversationId: mapping.externalConversationId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n deliveryStatus: 'pending',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(link)\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the link lookup above is not atomic with this\n // insert, so two deliveries of the same message (a replayed\n // `messages.message.sent`, or an overlapping worker retry) can both\n // reach here. The `message_channel_links_message_uq` index rejects the\n // loser with a 23505. Defer to the winning job \u2014 re-read its link on a\n // fresh fork and report `already_delivered` \u2014 instead of re-invoking the\n // adapter (double send) or letting the raw error dead-letter the job.\n if (isUniqueViolation(flushErr)) {\n const winner = await findOneWithDecryption(\n em.fork(),\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (winner) {\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: winner.id,\n }\n }\n }\n throw flushErr\n }\n }\n\n // (2 cont.) Decrypted credentials via the integrations module (if available).\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve(\n 'integrationCredentialsService',\n ) as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected last. See\n // review R2-C1 / N1 (2026-05-26).\n //\n // Key the org on the CHANNEL's own org, not the message's org. Tenant-wide\n // channels store `organization_id = NULL` and their credentials live at\n // `organization_id = tenantId` (see connect-credential-channel.ts), so\n // `channel.organizationId ?? tenantId` matches the write key for both\n // org-scoped and tenant-wide channels. Keying on the message org would\n // resolve `{}` the moment a tenant-wide provider delivers from a message\n // that carries a non-null org.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n\n // (4) Credential refresh if OAuth + near expiry, or forced by retry.\n let integrationLog: IntegrationLogLike | null = null\n try {\n integrationLog = ctx.container.resolve('integrationLogService') as IntegrationLogLike\n } catch {\n integrationLog = null\n }\n const refreshResult = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n force: Boolean(input.forceCredentialRefresh),\n },\n {\n credentialsService,\n logger: (...args) => moduleLogger.warn('credential refresh diagnostic', { details: args }),\n },\n )\n credentials = refreshResult.credentials\n\n // (4b) Spec B \u2014 get-or-create the per-thread crypto token and inject it\n // into the outbound payload BEFORE the adapter converts it. The token\n // travels as both a `References` header (via channelMetadata.references)\n // and a hidden body marker so inbound replies can be threaded back to\n // this conversation even when the recipient's MUA strips RFC 5322\n // headers. Idempotent on retry \u2014 same token reused per thread.\n let threadToken: string | null = null\n try {\n const { token } = await getOrCreateThreadToken(em, {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageThreadId: mapping.messageThreadId,\n })\n threadToken = token\n } catch (tokenErr) {\n // Token creation should never block a send \u2014 if it fails, the message\n // still goes out, just without our thread-token attachment point.\n // Threading falls back to the JWZ strategy via Message-Id headers.\n moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })\n }\n\n // (4c) Chat-provider reply threading \u2014 resolve the provider-native id of the\n // message this one answers so a threading adapter can attach it (Discord's\n // `message_reference`). Email adapters ignore this and keep threading on the\n // `inReplyTo` / `references` headers built above. Best-effort like the thread\n // token: an unresolvable parent sends an unthreaded reply rather than failing\n // a delivery.\n let replyToExternalId: string | null = null\n try {\n replyToExternalId = await resolveOutboundReplyExternalId(em, {\n parentMessageId: message.parentMessageId ?? null,\n externalConversationId: mapping.externalConversationId,\n capabilities: adapter.capabilities,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n })\n } catch (replyRefErr) {\n moduleLogger.warn('outbound reply reference unavailable, sending unthreaded', {\n err: replyRefErr,\n })\n }\n if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {\n // The provider threads and the caller asked for a reply, yet nothing\n // resolved \u2014 the parent never reached this channel, or it belongs to\n // another conversation. Delivery is unaffected, but say so: an unthreaded\n // reply is otherwise indistinguishable from a non-reply, which is exactly\n // the silent-unreachability shape #5541 was filed about.\n moduleLogger.debug('reply parent has no external id on this channel, sending unthreaded', {\n messageId: message.id,\n parentMessageId: message.parentMessageId,\n conversationId: mapping.externalConversationId,\n })\n }\n\n // (5) + (6) Convert + send.\n try {\n const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}\n const outboundHtml = typeof outboundPayload.html === 'string' ? outboundPayload.html : null\n const outboundText = typeof outboundPayload.text === 'string' ? outboundPayload.text : null\n let outboundBody = outboundHtml ?? outboundText ?? message.body ?? ''\n const outboundBodyFormat = outboundHtml\n ? 'html'\n : ((message.bodyFormat as 'text' | 'markdown' | 'html') ?? 'text')\n\n // Pre-existing channelMetadata.references (string[]) so we can extend\n // it with the synthetic thread-token id without disturbing other refs\n // (e.g. the recipient's own reply chain).\n const baseMetadata = (link.channelMetadata as Record<string, unknown> | undefined) ?? {}\n const existingRefs = Array.isArray(baseMetadata.references)\n ? (baseMetadata.references as unknown[]).filter(\n (value): value is string => typeof value === 'string',\n )\n : []\n let mergedReferences = existingRefs\n if (threadToken && !outboundBody.includes(`[OM:${threadToken}]`)) {\n // Append the body footer for the corresponding format. The hidden\n // HTML span is `display:none`; the plain-text trailer is a small\n // bracketed marker on its own line. Both survive most reply clients.\n const footer = buildBodyFooter(threadToken)\n if (outboundBodyFormat === 'html') {\n const closingBody = outboundBody.lastIndexOf('</body>')\n outboundBody =\n closingBody >= 0\n ? `${outboundBody.slice(0, closingBody)}${footer.html}${outboundBody.slice(closingBody)}`\n : `${outboundBody}${footer.html}`\n } else {\n outboundBody = `${outboundBody}${footer.plain}`\n }\n const refId = buildReferencesId(threadToken)\n if (!mergedReferences.includes(refId)) {\n mergedReferences = [...mergedReferences, refId]\n }\n }\n const converted = await adapter.convertOutbound({\n body: outboundBody,\n bodyFormat: outboundBodyFormat,\n channelMetadata: {\n thread_id: mapping.externalThreadRef,\n // Reply targeting is hub-resolved only. `send-as-user` merges\n // caller-supplied metadata onto the link, so every reply-targeting key\n // is stripped off the stored metadata here rather than merely\n // out-ranked below: merge order settles the contest only when the hub\n // resolved a parent, and the uncontested case \u2014 no `parentMessageId`,\n // or a parent that does not resolve \u2014 is exactly the one a caller\n // controls.\n ...stripCallerReplyTargeting(baseMetadata),\n references: mergedReferences,\n ...(threadToken ? { omThreadToken: threadToken } : {}),\n ...(replyToExternalId ? { replyToExternalId } : {}),\n },\n })\n\n // NOTE \u2014 at-least-once delivery (accepted v1 semantics). This provider\n // send is a non-transactional external side effect. The terminal-status\n // short-circuit above and the `message_channel_links_message_uq` index\n // prevent duplicate link records and re-sends after a *completed*\n // delivery, but if the process crashes in the narrow window between this\n // call returning and the success flush below, the link stays `pending`\n // and a worker retry re-invokes the adapter \u2014 the recipient may receive a\n // duplicate. This is deliberate: email providers (Gmail/SMTP) expose no\n // idempotent-send key nor a reliable \"did message X send?\" query, so\n // re-sending is preferred over risking a dropped message.\n const sendResult = await adapter.sendMessage({\n conversationId: mapping.externalThreadRef,\n content: converted.content,\n credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n },\n metadata: converted.metadata,\n })\n\n if (sendResult.status === 'failed') {\n throw new Error(sendResult.error ?? `Adapter '${adapter.providerKey}' reported send failure`)\n }\n\n // (7) Persist success records.\n //\n // Pre-generate the ExternalMessage PK client-side. The PK uses\n // `defaultRaw: 'gen_random_uuid()'`, so `externalMessage.id` is undefined\n // until after the INSERT returns \u2014 writing `link.externalMessageId =\n // externalMessage.id` before the flush would silently persist NULL on the\n // link's FK. Mirrors the inbound ingest path (ingest-inbound-message.ts).\n const externalMessageRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: channel.id,\n conversationId: mapping.externalConversationId,\n externalMessageId: sendResult.externalMessageId,\n direction: 'outbound',\n senderIdentifier: null,\n senderDisplayName: null,\n providerTimestamp: new Date(),\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // A successful send proves the credentials are valid \u2014 clear any prior\n // `requires_reauth` / `error` state so a recovered channel doesn't keep\n // showing a stale reconnect banner (e.g. after a forced-refresh retry).\n if (channel.status === 'requires_reauth' || channel.status === 'error') {\n channel.status = 'connected'\n }\n\n link.deliveryStatus = sendResult.status === 'sent' ? 'sent' : 'queued'\n link.externalMessageId = externalMessageRowId\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n ...(converted.metadata ?? {}),\n externalMessageId: sendResult.externalMessageId,\n // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ\n // strategy in lib/thread-matcher) and sent-folder dedup can resolve this\n // outbound message. Adapters that build the message themselves (Gmail)\n // return it in `converted.metadata.messageId`; IMAP/SMTP lets\n // the transport mint it, surfacing it only as `sendResult.externalMessageId`\n // (the RFC2822 id the recipient replies to) \u2014 fall back to that.\n // Store it bracket-stripped to match the inbound convention\n // (`normalizeMimeInbound` strips), so the JWZ matcher and sent-folder dedup \u2014\n // which compare against stripped ids \u2014 resolve it. `assembleRfc2822`\n // re-applies brackets when this id is later used to build reply headers.\n messageId: stripBrackets(\n stringOrUndefined((converted.metadata as Record<string, unknown> | undefined)?.messageId) ??\n sendResult.externalMessageId,\n ),\n }\n await em.flush()\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.sent',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'delivered',\n messageId: message.id,\n channelLinkId: link.id,\n externalMessageId: externalMessage.id,\n providerKey: channel.providerKey,\n }\n } catch (sendErr) {\n // (8) Failure path \u2014 classify, persist, emit, return.\n const classification = classifyOutboundError(sendErr)\n const requiresReauth = isReauthError(classification)\n link.deliveryStatus = 'failed'\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n lastError: classification.message,\n lastErrorAt: new Date().toISOString(),\n transient: classification.transient,\n requiresReauth,\n }\n // A 401 / invalid_grant means the stored credentials are dead and no\n // retry will help \u2014 flip the channel to `requires_reauth` (mirrors the\n // inbound poll path) so the operator gets a reconnect signal instead of\n // silently-failing sends. A later successful send self-heals back to\n // `connected` (see the success path above).\n if (requiresReauth) {\n channel.status = 'requires_reauth'\n }\n await em.flush()\n\n try {\n await integrationLog?.error?.({\n integrationId: `channel_${channel.providerKey}`,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n channelId: channel.id,\n messageId: message.id,\n status: classification.status ?? null,\n transient: classification.transient,\n message: classification.message,\n })\n } catch {\n // best-effort logging\n }\n\n if (requiresReauth) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.delivery_failed',\n {\n messageId: message.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n transient: classification.transient,\n error: classification.message,\n status: classification.status ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'failed',\n messageId: message.id,\n channelLinkId: link.id,\n providerKey: channel.providerKey,\n error: classification.message,\n transient: classification.transient,\n requiresReauth,\n }\n }\n },\n}\n\nregisterCommand(deliverOutboundMessageCommand)\n\nexport default deliverOutboundMessageCommand\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
2
|
+
import { ExternalMessage, MessageChannelLink } from "../data/entities.js";
|
|
3
|
+
async function resolveOutboundReplyExternalId(em, input) {
|
|
4
|
+
if (!input.parentMessageId) return null;
|
|
5
|
+
if (input.capabilities?.threading !== true) return null;
|
|
6
|
+
const dscope = {
|
|
7
|
+
tenantId: input.scope.tenantId,
|
|
8
|
+
organizationId: input.scope.organizationId
|
|
9
|
+
};
|
|
10
|
+
const parentLink = await findOneWithDecryption(
|
|
11
|
+
em,
|
|
12
|
+
MessageChannelLink,
|
|
13
|
+
{
|
|
14
|
+
messageId: input.parentMessageId,
|
|
15
|
+
tenantId: input.scope.tenantId,
|
|
16
|
+
organizationId: input.scope.organizationId
|
|
17
|
+
},
|
|
18
|
+
void 0,
|
|
19
|
+
dscope
|
|
20
|
+
);
|
|
21
|
+
if (!parentLink?.externalMessageId) return null;
|
|
22
|
+
if (parentLink.externalConversationId !== input.externalConversationId) return null;
|
|
23
|
+
const parentExternal = await findOneWithDecryption(
|
|
24
|
+
em,
|
|
25
|
+
ExternalMessage,
|
|
26
|
+
{
|
|
27
|
+
id: parentLink.externalMessageId,
|
|
28
|
+
tenantId: input.scope.tenantId,
|
|
29
|
+
organizationId: input.scope.organizationId
|
|
30
|
+
},
|
|
31
|
+
void 0,
|
|
32
|
+
dscope
|
|
33
|
+
);
|
|
34
|
+
if (!parentExternal) return null;
|
|
35
|
+
if (parentExternal.conversationId !== input.externalConversationId) return null;
|
|
36
|
+
const externalId = parentExternal.externalMessageId;
|
|
37
|
+
return typeof externalId === "string" && externalId.length > 0 ? externalId : null;
|
|
38
|
+
}
|
|
39
|
+
const REPLY_TARGETING_METADATA_KEYS = ["replyToExternalId", "messageReferenceId"];
|
|
40
|
+
function stripCallerReplyTargeting(metadata) {
|
|
41
|
+
const cleaned = { ...metadata };
|
|
42
|
+
for (const key of REPLY_TARGETING_METADATA_KEYS) delete cleaned[key];
|
|
43
|
+
return cleaned;
|
|
44
|
+
}
|
|
45
|
+
export {
|
|
46
|
+
resolveOutboundReplyExternalId,
|
|
47
|
+
stripCallerReplyTargeting
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=outbound-reply-ref.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/communication_channels/lib/outbound-reply-ref.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { ChannelCapabilities } from './adapter'\nimport { ExternalMessage, MessageChannelLink } from '../data/entities'\n\nexport interface OutboundReplyRefInput {\n /** `Message.parentMessageId` of the message being delivered. */\n parentMessageId: string | null | undefined\n /** `ChannelThreadMapping.externalConversationId` the message is being sent to. */\n externalConversationId: string\n capabilities: Pick<ChannelCapabilities, 'threading'> | null | undefined\n scope: { tenantId: string; organizationId: string | null }\n}\n\n/**\n * Resolve the provider-native id of the message an outbound reply answers, so\n * chat adapters can attach it (Discord `message_reference`, and any provider\n * that threads by message id rather than by RFC 5322 headers).\n *\n * Email providers thread through `inReplyTo` / `references`, which the hub has\n * always produced. Chat providers had no equivalent: `capabilities.threading`\n * described a reply-attachment the hub could never ask for, because nothing on\n * the outbound path wrote the parent's external id into channel metadata\n * (#5541 \u2014 the flag had to be declared `false` to stay honest). This is that\n * missing producer.\n *\n * The hub stores everything needed already: the parent hub message owns a\n * `MessageChannelLink` (unique per `message_id`) whose `external_message_id`\n * points at the `ExternalMessage` row carrying the provider's own id \u2014 written\n * by both the inbound ingest and outbound delivery paths, so a reply can answer\n * an inbound message or one of our own sends.\n *\n * Returns `null` \u2014 never throws a routing decision at the caller \u2014 when the\n * provider does not thread, the message is not a reply, the parent never\n * reached this channel, or the parent belongs to a different conversation.\n * Threading is an enhancement to a delivery, never a precondition for one.\n */\nexport async function resolveOutboundReplyExternalId(\n em: EntityManager,\n input: OutboundReplyRefInput,\n): Promise<string | null> {\n // A non-reply can never produce a reference, so check that before anything\n // that costs a query.\n if (!input.parentMessageId) return null\n // Gate on the adapter's own declaration: handing a non-threading provider a key\n // it silently drops is exactly the mismatch `ChannelCapabilities` exists to\n // prevent. Note this gate does NOT spare header-threading providers \u2014 the\n // shared email profile declares `threading: true` (`email-capabilities.ts`), so\n // an email reply pays for both lookups below and its converter then ignores the\n // result in favor of `inReplyTo` / `references`. Correct but wasteful;\n // distinguishing id-threading from header-threading needs a capability the\n // contract does not have yet (#5691).\n if (input.capabilities?.threading !== true) return null\n\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId,\n }\n\n const parentLink = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: input.parentMessageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId,\n },\n undefined,\n dscope,\n )\n if (!parentLink?.externalMessageId) return null\n\n // A parent that lives in another conversation would make the provider reject\n // the send (Discord answers `400 Unknown message` for a cross-channel\n // reference). Dropping the reference degrades to an unthreaded reply, which\n // is strictly better than a failed delivery.\n if (parentLink.externalConversationId !== input.externalConversationId) return null\n\n const parentExternal = await findOneWithDecryption(\n em,\n ExternalMessage,\n {\n id: parentLink.externalMessageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId,\n },\n undefined,\n dscope,\n )\n\n if (!parentExternal) return null\n // Re-assert the conversation invariant on the row that actually carries the\n // provider id. Both producers write the link and the external message together,\n // so these cannot disagree today \u2014 checking here keeps the guard correct even if\n // that coupling is ever relaxed, rather than trusting a second table's field.\n if (parentExternal.conversationId !== input.externalConversationId) return null\n\n const externalId = parentExternal.externalMessageId\n return typeof externalId === 'string' && externalId.length > 0 ? externalId : null\n}\n\n/**\n * Outbound `channelMetadata` keys that decide which provider message a reply\n * attaches to.\n *\n * - `replyToExternalId` \u2014 the hub's own key, produced by\n * `resolveOutboundReplyExternalId` above.\n * - `messageReferenceId` \u2014 Discord's already-converted equivalent. Its converter\n * has to accept that key so the value survives the hub's convert\u2192send double\n * conversion (#5541), and the converter cannot tell the two passes apart, so\n * the key is equally accepted on the first, caller-controlled pass.\n */\nconst REPLY_TARGETING_METADATA_KEYS = ['replyToExternalId', 'messageReferenceId'] as const\n\n/**\n * Drop every reply-targeting key from stored link metadata, so reply targeting\n * on the outbound path is hub-resolved only.\n *\n * `send-as-user` merges caller-supplied `channelMetadata` onto the\n * `MessageChannelLink`, and `deliver-outbound-message` reads that row back as\n * the converter's base metadata. Merge order alone only settles the contest when\n * the hub actually resolved a parent; the uncontested case \u2014 a message with no\n * `parentMessageId`, or one whose parent legitimately fails to resolve \u2014 is\n * exactly the one a caller controls, and there nothing would overwrite a\n * smuggled key. Without this, a `communication_channels.manage` caller could aim\n * a reply at an arbitrary provider message id and have the bot's message render\n * as a reply to a message the hub never resolved and never validated.\n *\n * Stripping is safe on a retry: the reference is re-resolved from\n * `parentMessageId` on every delivery attempt, never read back from the link.\n */\nexport function stripCallerReplyTargeting(\n metadata: Record<string, unknown>,\n): Record<string, unknown> {\n const cleaned = { ...metadata }\n for (const key of REPLY_TARGETING_METADATA_KEYS) delete cleaned[key]\n return cleaned\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,6BAA6B;AAEtC,SAAS,iBAAiB,0BAA0B;AAkCpD,eAAsB,+BACpB,IACA,OACwB;AAGxB,MAAI,CAAC,MAAM,gBAAiB,QAAO;AASnC,MAAI,MAAM,cAAc,cAAc,KAAM,QAAO;AAEnD,QAAM,SAAS;AAAA,IACb,UAAU,MAAM,MAAM;AAAA,IACtB,gBAAgB,MAAM,MAAM;AAAA,EAC9B;AAEA,QAAM,aAAa,MAAM;AAAA,IACvB;AAAA,IACA;AAAA,IACA;AAAA,MACE,WAAW,MAAM;AAAA,MACjB,UAAU,MAAM,MAAM;AAAA,MACtB,gBAAgB,MAAM,MAAM;AAAA,IAC9B;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACA,MAAI,CAAC,YAAY,kBAAmB,QAAO;AAM3C,MAAI,WAAW,2BAA2B,MAAM,uBAAwB,QAAO;AAE/E,QAAM,iBAAiB,MAAM;AAAA,IAC3B;AAAA,IACA;AAAA,IACA;AAAA,MACE,IAAI,WAAW;AAAA,MACf,UAAU,MAAM,MAAM;AAAA,MACtB,gBAAgB,MAAM,MAAM;AAAA,IAC9B;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,MAAI,CAAC,eAAgB,QAAO;AAK5B,MAAI,eAAe,mBAAmB,MAAM,uBAAwB,QAAO;AAE3E,QAAM,aAAa,eAAe;AAClC,SAAO,OAAO,eAAe,YAAY,WAAW,SAAS,IAAI,aAAa;AAChF;AAaA,MAAM,gCAAgC,CAAC,qBAAqB,oBAAoB;AAmBzE,SAAS,0BACd,UACyB;AACzB,QAAM,UAAU,EAAE,GAAG,SAAS;AAC9B,aAAW,OAAO,8BAA+B,QAAO,QAAQ,GAAG;AACnE,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7113.1.9d83dca10c",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -252,16 +252,16 @@
|
|
|
252
252
|
"zod": "^4.4.3"
|
|
253
253
|
},
|
|
254
254
|
"peerDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
256
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
257
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7113.1.9d83dca10c",
|
|
256
|
+
"@open-mercato/shared": "0.7.1-develop.7113.1.9d83dca10c",
|
|
257
|
+
"@open-mercato/ui": "0.7.1-develop.7113.1.9d83dca10c",
|
|
258
258
|
"react": "^19.0.0",
|
|
259
259
|
"react-dom": "^19.0.0"
|
|
260
260
|
},
|
|
261
261
|
"devDependencies": {
|
|
262
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
263
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
264
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
262
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7113.1.9d83dca10c",
|
|
263
|
+
"@open-mercato/shared": "0.7.1-develop.7113.1.9d83dca10c",
|
|
264
|
+
"@open-mercato/ui": "0.7.1-develop.7113.1.9d83dca10c",
|
|
265
265
|
"@testing-library/dom": "^10.4.1",
|
|
266
266
|
"@testing-library/jest-dom": "^7.0.0",
|
|
267
267
|
"@testing-library/react": "^16.3.1",
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getOrCreateThreadToken,
|
|
14
14
|
} from '../lib/thread-token'
|
|
15
15
|
import { stringOrUndefined, stripBrackets } from '../lib/email-mime'
|
|
16
|
+
import { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from '../lib/outbound-reply-ref'
|
|
16
17
|
import type { ChannelAdapterRegistry } from '../lib/registry'
|
|
17
18
|
import { isUniqueViolation } from '../lib/pg-errors'
|
|
18
19
|
import { Message } from '../../messages/data/entities'
|
|
@@ -348,6 +349,41 @@ const deliverOutboundMessageCommand: CommandHandler<
|
|
|
348
349
|
moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })
|
|
349
350
|
}
|
|
350
351
|
|
|
352
|
+
// (4c) Chat-provider reply threading — resolve the provider-native id of the
|
|
353
|
+
// message this one answers so a threading adapter can attach it (Discord's
|
|
354
|
+
// `message_reference`). Email adapters ignore this and keep threading on the
|
|
355
|
+
// `inReplyTo` / `references` headers built above. Best-effort like the thread
|
|
356
|
+
// token: an unresolvable parent sends an unthreaded reply rather than failing
|
|
357
|
+
// a delivery.
|
|
358
|
+
let replyToExternalId: string | null = null
|
|
359
|
+
try {
|
|
360
|
+
replyToExternalId = await resolveOutboundReplyExternalId(em, {
|
|
361
|
+
parentMessageId: message.parentMessageId ?? null,
|
|
362
|
+
externalConversationId: mapping.externalConversationId,
|
|
363
|
+
capabilities: adapter.capabilities,
|
|
364
|
+
scope: {
|
|
365
|
+
tenantId: input.scope.tenantId,
|
|
366
|
+
organizationId: input.scope.organizationId ?? null,
|
|
367
|
+
},
|
|
368
|
+
})
|
|
369
|
+
} catch (replyRefErr) {
|
|
370
|
+
moduleLogger.warn('outbound reply reference unavailable, sending unthreaded', {
|
|
371
|
+
err: replyRefErr,
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {
|
|
375
|
+
// The provider threads and the caller asked for a reply, yet nothing
|
|
376
|
+
// resolved — the parent never reached this channel, or it belongs to
|
|
377
|
+
// another conversation. Delivery is unaffected, but say so: an unthreaded
|
|
378
|
+
// reply is otherwise indistinguishable from a non-reply, which is exactly
|
|
379
|
+
// the silent-unreachability shape #5541 was filed about.
|
|
380
|
+
moduleLogger.debug('reply parent has no external id on this channel, sending unthreaded', {
|
|
381
|
+
messageId: message.id,
|
|
382
|
+
parentMessageId: message.parentMessageId,
|
|
383
|
+
conversationId: mapping.externalConversationId,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
351
387
|
// (5) + (6) Convert + send.
|
|
352
388
|
try {
|
|
353
389
|
const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}
|
|
@@ -392,9 +428,17 @@ const deliverOutboundMessageCommand: CommandHandler<
|
|
|
392
428
|
bodyFormat: outboundBodyFormat,
|
|
393
429
|
channelMetadata: {
|
|
394
430
|
thread_id: mapping.externalThreadRef,
|
|
395
|
-
|
|
431
|
+
// Reply targeting is hub-resolved only. `send-as-user` merges
|
|
432
|
+
// caller-supplied metadata onto the link, so every reply-targeting key
|
|
433
|
+
// is stripped off the stored metadata here rather than merely
|
|
434
|
+
// out-ranked below: merge order settles the contest only when the hub
|
|
435
|
+
// resolved a parent, and the uncontested case — no `parentMessageId`,
|
|
436
|
+
// or a parent that does not resolve — is exactly the one a caller
|
|
437
|
+
// controls.
|
|
438
|
+
...stripCallerReplyTargeting(baseMetadata),
|
|
396
439
|
references: mergedReferences,
|
|
397
440
|
...(threadToken ? { omThreadToken: threadToken } : {}),
|
|
441
|
+
...(replyToExternalId ? { replyToExternalId } : {}),
|
|
398
442
|
},
|
|
399
443
|
})
|
|
400
444
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
|
+
import type { ChannelCapabilities } from './adapter'
|
|
4
|
+
import { ExternalMessage, MessageChannelLink } from '../data/entities'
|
|
5
|
+
|
|
6
|
+
export interface OutboundReplyRefInput {
|
|
7
|
+
/** `Message.parentMessageId` of the message being delivered. */
|
|
8
|
+
parentMessageId: string | null | undefined
|
|
9
|
+
/** `ChannelThreadMapping.externalConversationId` the message is being sent to. */
|
|
10
|
+
externalConversationId: string
|
|
11
|
+
capabilities: Pick<ChannelCapabilities, 'threading'> | null | undefined
|
|
12
|
+
scope: { tenantId: string; organizationId: string | null }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the provider-native id of the message an outbound reply answers, so
|
|
17
|
+
* chat adapters can attach it (Discord `message_reference`, and any provider
|
|
18
|
+
* that threads by message id rather than by RFC 5322 headers).
|
|
19
|
+
*
|
|
20
|
+
* Email providers thread through `inReplyTo` / `references`, which the hub has
|
|
21
|
+
* always produced. Chat providers had no equivalent: `capabilities.threading`
|
|
22
|
+
* described a reply-attachment the hub could never ask for, because nothing on
|
|
23
|
+
* the outbound path wrote the parent's external id into channel metadata
|
|
24
|
+
* (#5541 — the flag had to be declared `false` to stay honest). This is that
|
|
25
|
+
* missing producer.
|
|
26
|
+
*
|
|
27
|
+
* The hub stores everything needed already: the parent hub message owns a
|
|
28
|
+
* `MessageChannelLink` (unique per `message_id`) whose `external_message_id`
|
|
29
|
+
* points at the `ExternalMessage` row carrying the provider's own id — written
|
|
30
|
+
* by both the inbound ingest and outbound delivery paths, so a reply can answer
|
|
31
|
+
* an inbound message or one of our own sends.
|
|
32
|
+
*
|
|
33
|
+
* Returns `null` — never throws a routing decision at the caller — when the
|
|
34
|
+
* provider does not thread, the message is not a reply, the parent never
|
|
35
|
+
* reached this channel, or the parent belongs to a different conversation.
|
|
36
|
+
* Threading is an enhancement to a delivery, never a precondition for one.
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveOutboundReplyExternalId(
|
|
39
|
+
em: EntityManager,
|
|
40
|
+
input: OutboundReplyRefInput,
|
|
41
|
+
): Promise<string | null> {
|
|
42
|
+
// A non-reply can never produce a reference, so check that before anything
|
|
43
|
+
// that costs a query.
|
|
44
|
+
if (!input.parentMessageId) return null
|
|
45
|
+
// Gate on the adapter's own declaration: handing a non-threading provider a key
|
|
46
|
+
// it silently drops is exactly the mismatch `ChannelCapabilities` exists to
|
|
47
|
+
// prevent. Note this gate does NOT spare header-threading providers — the
|
|
48
|
+
// shared email profile declares `threading: true` (`email-capabilities.ts`), so
|
|
49
|
+
// an email reply pays for both lookups below and its converter then ignores the
|
|
50
|
+
// result in favor of `inReplyTo` / `references`. Correct but wasteful;
|
|
51
|
+
// distinguishing id-threading from header-threading needs a capability the
|
|
52
|
+
// contract does not have yet (#5691).
|
|
53
|
+
if (input.capabilities?.threading !== true) return null
|
|
54
|
+
|
|
55
|
+
const dscope = {
|
|
56
|
+
tenantId: input.scope.tenantId,
|
|
57
|
+
organizationId: input.scope.organizationId,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parentLink = await findOneWithDecryption(
|
|
61
|
+
em,
|
|
62
|
+
MessageChannelLink,
|
|
63
|
+
{
|
|
64
|
+
messageId: input.parentMessageId,
|
|
65
|
+
tenantId: input.scope.tenantId,
|
|
66
|
+
organizationId: input.scope.organizationId,
|
|
67
|
+
},
|
|
68
|
+
undefined,
|
|
69
|
+
dscope,
|
|
70
|
+
)
|
|
71
|
+
if (!parentLink?.externalMessageId) return null
|
|
72
|
+
|
|
73
|
+
// A parent that lives in another conversation would make the provider reject
|
|
74
|
+
// the send (Discord answers `400 Unknown message` for a cross-channel
|
|
75
|
+
// reference). Dropping the reference degrades to an unthreaded reply, which
|
|
76
|
+
// is strictly better than a failed delivery.
|
|
77
|
+
if (parentLink.externalConversationId !== input.externalConversationId) return null
|
|
78
|
+
|
|
79
|
+
const parentExternal = await findOneWithDecryption(
|
|
80
|
+
em,
|
|
81
|
+
ExternalMessage,
|
|
82
|
+
{
|
|
83
|
+
id: parentLink.externalMessageId,
|
|
84
|
+
tenantId: input.scope.tenantId,
|
|
85
|
+
organizationId: input.scope.organizationId,
|
|
86
|
+
},
|
|
87
|
+
undefined,
|
|
88
|
+
dscope,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if (!parentExternal) return null
|
|
92
|
+
// Re-assert the conversation invariant on the row that actually carries the
|
|
93
|
+
// provider id. Both producers write the link and the external message together,
|
|
94
|
+
// so these cannot disagree today — checking here keeps the guard correct even if
|
|
95
|
+
// that coupling is ever relaxed, rather than trusting a second table's field.
|
|
96
|
+
if (parentExternal.conversationId !== input.externalConversationId) return null
|
|
97
|
+
|
|
98
|
+
const externalId = parentExternal.externalMessageId
|
|
99
|
+
return typeof externalId === 'string' && externalId.length > 0 ? externalId : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Outbound `channelMetadata` keys that decide which provider message a reply
|
|
104
|
+
* attaches to.
|
|
105
|
+
*
|
|
106
|
+
* - `replyToExternalId` — the hub's own key, produced by
|
|
107
|
+
* `resolveOutboundReplyExternalId` above.
|
|
108
|
+
* - `messageReferenceId` — Discord's already-converted equivalent. Its converter
|
|
109
|
+
* has to accept that key so the value survives the hub's convert→send double
|
|
110
|
+
* conversion (#5541), and the converter cannot tell the two passes apart, so
|
|
111
|
+
* the key is equally accepted on the first, caller-controlled pass.
|
|
112
|
+
*/
|
|
113
|
+
const REPLY_TARGETING_METADATA_KEYS = ['replyToExternalId', 'messageReferenceId'] as const
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Drop every reply-targeting key from stored link metadata, so reply targeting
|
|
117
|
+
* on the outbound path is hub-resolved only.
|
|
118
|
+
*
|
|
119
|
+
* `send-as-user` merges caller-supplied `channelMetadata` onto the
|
|
120
|
+
* `MessageChannelLink`, and `deliver-outbound-message` reads that row back as
|
|
121
|
+
* the converter's base metadata. Merge order alone only settles the contest when
|
|
122
|
+
* the hub actually resolved a parent; the uncontested case — a message with no
|
|
123
|
+
* `parentMessageId`, or one whose parent legitimately fails to resolve — is
|
|
124
|
+
* exactly the one a caller controls, and there nothing would overwrite a
|
|
125
|
+
* smuggled key. Without this, a `communication_channels.manage` caller could aim
|
|
126
|
+
* a reply at an arbitrary provider message id and have the bot's message render
|
|
127
|
+
* as a reply to a message the hub never resolved and never validated.
|
|
128
|
+
*
|
|
129
|
+
* Stripping is safe on a retry: the reference is re-resolved from
|
|
130
|
+
* `parentMessageId` on every delivery attempt, never read back from the link.
|
|
131
|
+
*/
|
|
132
|
+
export function stripCallerReplyTargeting(
|
|
133
|
+
metadata: Record<string, unknown>,
|
|
134
|
+
): Record<string, unknown> {
|
|
135
|
+
const cleaned = { ...metadata }
|
|
136
|
+
for (const key of REPLY_TARGETING_METADATA_KEYS) delete cleaned[key]
|
|
137
|
+
return cleaned
|
|
138
|
+
}
|