/** * Edit message intercept stage: handles inbound edited_message events by * updating the original message content in-place. No new agent loop is * triggered for edits. * * The retry-with-backoff lookup accounts for race conditions where the edit * webhook arrives before the original message has been linked via * linkMessage (the original agent loop may still be in progress). * * For Slack edits, the stage additionally stamps `slackMeta.editedAt` into * the message's metadata via a single transactional content+metadata update, * so downstream renderers can surface the edited marker. * * An edit belongs to the conversation of the message it changes, so the stage * resolves that message before it records anything, and an edit with no * resolvable target is dropped. */ import type { ChannelId } from "../../../channels/types.js"; import { mergeSlackMetadata, readSlackMetadata, } from "../../../messaging/providers/slack/message-metadata.js"; import type { MessageRow } from "../../../persistence/conversation-crud.js"; import { getMessageById, updateMessageContent, updateMessageContentAndMetadata, } from "../../../persistence/conversation-crud.js"; import { findInboundEvent, findMessageBySourceId, recordInbound, } from "../../../persistence/delivery-crud.js"; import { enqueueLexicalIndexForMessage } from "../../../persistence/job-handlers/message-lexical.js"; import { stringifyMessageContent } from "../../../persistence/message-content.js"; import { safeParseRecord } from "../../../util/json.js"; import { getLogger } from "../../../util/logger.js"; const log = getLogger("runtime-http"); // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export interface EditInterceptParams { sourceChannel: ChannelId; conversationExternalId: string; externalMessageId: string; sourceMessageId: string; sourceThreadId?: string; assistantId: string; content: string | undefined; /** Channel ID for channel-level interaction tracking. */ channelId?: string; } /** * Handle an inbound edit event by deduplicating and updating the original * message content. * * Returns a Response on success (the pipeline should short-circuit), or * null if this stage does not apply. */ export async function handleEditIntercept( params: EditInterceptParams, ): Promise> { const { sourceChannel, conversationExternalId, externalMessageId, sourceMessageId, sourceThreadId, assistantId, content, } = params; // Dedup the edit event itself (retried edited_message webhooks) before the // lookup below, so a redelivery answers immediately instead of waiting out // the retry window a second time. const alreadyRecorded = findInboundEvent( sourceChannel, conversationExternalId, externalMessageId, ); if (alreadyRecorded) { return { accepted: true, duplicate: true, eventId: alreadyRecorded.eventId, }; } // Retry lookup a few times -- the original message may still be processing // (linkMessage hasn't been called yet). Short backoff avoids losing edits // that arrive while the original agent loop is in progress. const EDIT_LOOKUP_RETRIES = 5; const EDIT_LOOKUP_DELAY_MS = 2000; let original: { messageId: string; conversationId: string } | null = null; for (let attempt = 0; attempt <= EDIT_LOOKUP_RETRIES; attempt++) { original = findMessageBySourceId( sourceChannel, conversationExternalId, sourceMessageId, ); if (original) { break; } if (attempt < EDIT_LOOKUP_RETRIES) { log.info( { assistantId, sourceMessageId, attempt: attempt + 1, maxAttempts: EDIT_LOOKUP_RETRIES, }, "Original message not linked yet, retrying edit lookup", ); await new Promise((resolve) => setTimeout(resolve, EDIT_LOOKUP_DELAY_MS)); } } if (!original) { // Nothing to edit, and nothing to record it against: recording it anyway // would resolve a conversation from the edit's own address and create one // there. // // Slack keeps this at `debug`, since the row may have been compacted or // never stored. Other channels keep the louder `warn`, since their edit // pipelines expect the row to exist. const missingTarget = { assistantId, sourceChannel, conversationExternalId, sourceMessageId, }; if (sourceChannel === "slack") { log.debug(missingTarget, "Slack edit target not found, ignoring"); } else { log.warn( missingTarget, "Could not find original message for edit after retries, ignoring", ); } return { accepted: true, duplicate: false, edited: false }; } // The edit belongs to the conversation of the message it changes. Resolving // one from the edit's own address would key it on the edited message's // timestamp and create a conversation there. const editResult = recordInbound( sourceChannel, conversationExternalId, externalMessageId, { sourceMessageId, conversationId: original.conversationId }, ); if (editResult.duplicate) { // Two deliveries of one edit raced through the probe above while neither // had recorded yet. The first owns the write; applying it twice would // re-enqueue lexical indexing for no change. return { accepted: true, duplicate: true, eventId: editResult.eventId }; } const newContent = content ?? ""; // Short-circuit no-op edits: Slack fires `message_changed` for link // unfurls and other decorations where the text is identical to the // previous revision. Skipping the DB write here covers that case and // also drops trivially-redundant edit webhooks. We only have the // authoritative previous text once the original row is located, so // this check lives after the lookup. const existingRow = getMessageById(original.messageId); if ( existingRow && stringifyMessageContent(existingRow.content) === newContent ) { log.debug( { assistantId, sourceChannel, sourceMessageId, messageId: original.messageId, }, "Edit text unchanged; skipping update", ); return { accepted: true, duplicate: false, noop: true, eventId: editResult.eventId, }; } if (sourceChannel === "slack") { // Slack edits stamp `slackMeta.editedAt` so the chronological // transcript renderer can surface the edited marker. The merge // tolerates rows that lack slackMeta enrichment by synthesizing // the minimum-required fields from the lookup data. applySlackEditMetadata({ messageId: original.messageId, existingRow, conversationExternalId, sourceMessageId, sourceThreadId, newContent, }); } else { updateMessageContent(original.messageId, newContent); } // The edit changed searchable text (the no-op guard above already returned // for identical content) and this path bypasses the `addMessage` persist // path, so reindex the message into the lexical index — the idempotent // upsert replaces the stale Qdrant point with the edited content. enqueueLexicalIndexForMessage(original.messageId); log.info( { assistantId, sourceMessageId, messageId: original.messageId }, "Updated message content from edited_message", ); return { accepted: true, duplicate: false, eventId: editResult.eventId, }; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** * Apply a Slack edit to the stored message: update content and stamp * `slackMeta.editedAt` in the same transaction. * * If the row already has a valid `slackMeta` sub-object, the merge preserves * all existing fields and only sets/refreshes `editedAt`. If the row lacks * `slackMeta` enrichment, the helper synthesizes the minimum-required fields * (`source`, `channelId`, `channelTs`, `eventKind`) from the values that * brought us here so the resulting metadata is still readable by * `readSlackMetadata`. */ function applySlackEditMetadata(params: { messageId: string; /** The stored row, already read by the caller's no-op guard. */ existingRow: MessageRow | null; conversationExternalId: string; sourceMessageId: string; sourceThreadId?: string; newContent: string; }): void { const { messageId, existingRow: row, conversationExternalId, sourceMessageId, sourceThreadId, newContent, } = params; const outerMetadata: Record = row?.metadata != null ? safeParseRecord(row.metadata) : {}; const existingSlackMeta = typeof outerMetadata.slackMeta === "string" ? outerMetadata.slackMeta : undefined; const editedAt = Date.now(); const parsedExisting = readSlackMetadata(existingSlackMeta ?? null); // When the row has no valid existing slackMeta, `mergeSlackMetadata` // would produce a record missing the required fields and fail subsequent // `readSlackMetadata` calls. Seed defaults from the lookup-derived facts // so the post-merge value is always a valid `SlackMessageMetadata`. const mergedSlackMeta = mergeSlackMetadata(existingSlackMeta ?? null, { ...(parsedExisting ? {} : { source: "slack" as const, channelId: conversationExternalId, channelTs: sourceMessageId, ...(sourceThreadId ? { threadTs: sourceThreadId } : {}), eventKind: "message" as const, }), editedAt, }); updateMessageContentAndMetadata(messageId, newContent, { slackMeta: mergedSlackMeta, }); }