diff --git a/doc/plans/2026-10-12-chat-channel-transports-refactor.md b/doc/plans/2026-10-12-chat-channel-transports-refactor.md new file mode 100644 index 00000000000..fdef6082391 --- /dev/null +++ b/doc/plans/2026-10-12-chat-channel-transports-refactor.md @@ -0,0 +1,124 @@ +# Chat channel transport workflows, batch three + +Base: `a2ac5c27157ab033a2e0e2731b1b8e35bf20e7b9`. The preceding stack +(#15976, #15979, #15981, #15985, #15987) is merged. The original service is +29,897 lines / 1,170,080 bytes. This batch creates five stacked PRs; merging is +separate work. + +| Order / boundary | Destination | Approximate lines moved / net reduction | Inputs and outputs | State and effects | Verification | +| --- | --- | --- | --- | --- | --- | +| 1. Slack session status | `chat-channels/slack/session-status.ts` | 342 / 330 | Endpoint lookup, credential lease, runtime fence, publication run identity, credentials and fetch; return processed count | Claim and settle existing durable status actions. Keep selected revision, attempt ownership and live test barrier. No worker or new state. | Existing public-service status, restart, rate-limit, concurrent selection, stop and Idle cases before/after. | +| 2. Slack accepted-file receipts | `chat-channels/slack/file-receipts.ts` | 900 / 840 | Runtime/credential authorization, publication eligibility and endpoint lookup; record/recover accepted upload receipts | Preserve same-attempt leases, exact receipt identities, publication links and transaction boundaries. Keep generic publication transport shared. | Existing upload recovery, competing retry deadline, revoked reach and no-duplicate-upload cases before/after. | +| 3. Telegram publication drafts | `chat-channels/telegram/publication-drafts.ts` | 394 / 370 | Current runtime and callback fence, source publication authority; reserve draft or handle stopped generation | Preserve runtime map identity, stop scope, draft revision and transactional invalidation. Keep maintenance and shared publication admission in their existing owners. | Existing durable private draft Stop, stale generation and publication recovery cases before/after. | +| 4. Photon incoming events and responses | `chat-channels/imessage-photon/events.ts` | 820 / 780 | Runtime callbacks, principal authorization, question delivery and shared inbound admission | Preserve receiver sequence/lease checks, native interaction binding, transaction ordering and deferred publication. No receiver/startup or credential-inspection ownership move. | Existing Photon public-service integration suite before/after. | +| 5. Teams file-transfer authority | `chat-channels/microsoft-teams/file-transfer-authority.ts` | 940 / 900 | Source proof, runtime/credential fence, principal authorization; return current recipient and immutable file authority | Keep source, recipient, Board intent and byte identity proofs together. Preserve map lifetimes and no-wait locks. Transport, maintenance cooldowns and cleanup stay with the publication worker. | Existing Teams current file authority, consent, composed Board transfer and stale-runtime cases before/after. | + +Use named, typed contracts and channel folders. Keep shared message admission, +provider startup/shutdown, generic publication transport and shared authorization +in the service. Do not introduce a mutable context bag or a runtime import back +to the service. Reconsider a candidate if its actual dependency contract proves +too broad. + +For each slice, inspect its execution path and callers, run baseline tests, move +mechanically, compare normalized syntax, rerun the same public entry-point tests, +and check server types and module boundaries. Finish CI and current-head review +before opening the next PR. Run broad local gates once at the cumulative handoff. +Record failures and skipped groups accurately; a retry does not erase a failed +run. Fixtures exercise assembled services and persistence, not live providers. + +The starting service function is 27,835 lines. Its 212 nested functions span +27,146 lines; 51 wiring/state/API statements span 435 lines. Imports span 423 +lines. The largest shared function remains `processMessage` at 2,166 lines. +Remap these measurements after the fifth extraction and stop at five PRs. + +## Slice 1: Slack session status + +- Service: 29,897 lines / 1,170,080 bytes → 29,553 lines / 1,156,944 bytes. +- Destination: 408 lines / 15,730 bytes. The durable claim, provider status call + and conditional settlement remain in one function. No state or worker moves. +- All six selected public-service cases passed before and after. They exercise + restart/rate-limit recovery, stale selection, Stop and publication-driven Idle. + Server typecheck, module boundaries, whitespace and normalized syntax parity + passed. The selection hook remains live through an options getter. +- Next: accepted-file receipt recovery. Keep the generic outbound publication + link writer shared and pass it as a typed callback to the Slack module. + +## Slice 2: Slack accepted-file receipts + +- Service: 29,553 lines / 1,156,944 bytes → 28,620 lines / 1,122,992 bytes. +- Destination: 1,000 lines / 36,807 bytes. Thirteen declarations move together: + receipt validation, recording, authorization, recovery, settlement and due-work + selection. The generic outbound-link writer remains shared. +- All nine selected public-service cases passed before and after. They cover + accepted upload recovery without reposting, original-attempt ownership, + competing retry deadlines, revoked reach and selected file publication. + Server typecheck, module boundaries and syntax parity passed. No state or + worker is added. Credential lease identity and all transactions are unchanged. +- Next: Telegram private draft reservation and stopped-generation callbacks, + retaining shared runtime-map identity and source-publication authorization. + +## Slice 3: Telegram publication drafts + +- Service: 28,620 lines / 1,122,992 bytes → 28,219 lines / 1,108,711 bytes. +- Destination: 461 lines / 16,860 bytes. Both functions preserve their original + bodies. Runtime maps remain the same per-service objects; the source authority + callback and credential guard are unchanged. +- All 34 durable private-draft Stop cases passed before and after, including + restart, bot-global identity allocation, concurrent Stop, transaction failure, + uncertain final delivery and retained stale callbacks. Server typecheck, + boundaries, whitespace and normalized syntax parity passed. +- Reassessment: Photon event handling can move without moving its receiver or + setup inspection. For Teams, extract source/recipient/file authority as a + coherent transaction-level API; keep transport protocol and scheduler lifetime + in the service to avoid passing unrelated runtime/worker dependencies. + +## Slice 4: Photon events + +- Service: 28,219 lines / 1,108,711 bytes → 27,398 lines / 1,079,439 bytes. +- Destination: 903 lines / 33,080 bytes. Provider failure, incoming event and + native response handling preserve all three function bodies. The receiver, + lease ownership, checkpoint callbacks and setup inspection remain in the + service; question delivery uses the original service object. +- All 18 Photon channel integration cases passed before and after. They use the + assembled Chat SDK runtime, provider fixture streams and a disposable database + to cover poll/confirmation responses, separate participant drafts, stale + controls, receiver lease fencing, groups, echoes, restart and continuation. + Server typecheck, boundaries, whitespace and syntax parity passed. +- Next: Teams file-transfer authority, with no transport or timer ownership move. + +## Slice 5: Teams file-transfer authority + +- Service: 27,398 lines / 1,079,439 bytes → 26,461 lines / 1,046,711 bytes. +- Destination: 1,041 lines / 36,532 bytes. Eleven declarations move into a + transaction-level authority factory exposing Board intent staging and fresh + authority derivation. It takes no database instance and starts no work. +- The original per-service runtime maps and retry-proof callbacks are passed + after the retry factory initializes. No-wait row locks, identity advisory + locks, source ordering and byte/recipient proof hashes preserve their bodies. +- All 50 selected Teams public-service cases passed before and after, including + exact recipient admission, retained source continuation, composed Board file + transfers, stale runtime callbacks and consent/file receipt recovery. Server + typecheck, module boundaries, whitespace and normalized syntax parity passed. + +## Reassessment after batch three + +The service falls from 29,897 lines / 1,170,080 bytes to 26,461 lines / +1,046,711 bytes: 3,436 lines (11.5%) and 123,369 bytes removed. The five new +modules total 3,813 lines / 139,009 bytes. All 117 selected cases passed before +and after their respective extractions. + +The service function spans 24,484 lines. Its 191 nested functions span 23,829 +lines; 54 state/wiring/API statements span 425 lines. Imports span 412 lines. +The largest remaining functions are `processMessage` (2,166 lines), +`handleAction` (1,074), `configureWithCredentialLease` (868), +`processSelectedPublication` (729), `handleModalSubmit` (708), `handleWebhook` +(685), `processLifecycleDelivery` (657), `applyProviderLifecycleEffect` (593), +and `ingestAttachments` (574). + +Future candidates include receipt-reaction recovery, provider-effect dispatch, +attachment ingestion and inbound delivery persistence/drain ownership. Teams +transport protocol and scheduling can be reconsidered after their lifetime +contract is explicit; this batch deliberately moved only authority proofs. +Do not move a large shared admission function wholesale merely to reduce LOC. +Stop this batch at five PRs. Cumulative local gates and current-head CI/review +results are recorded in the final PR; per-slice PRs retain their own evidence. diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 0cfbfa5fdbf..31338f7acc1 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,10 +1,15 @@ +import { createTeamsFileAuthority } from "./chat-channels/microsoft-teams/file-transfer-authority.js"; +import { createPhotonEvents } from "./chat-channels/imessage-photon/events.js"; +import { createTelegramPublicationDrafts } from "./chat-channels/telegram/publication-drafts.js"; +import { createSlackFileReceipts } from "./chat-channels/slack/file-receipts.js"; +import { createSlackSessionStatus } from "./chat-channels/slack/session-status.js"; import { createDiscordCommands } from "./chat-channels/discord/commands.js"; import { createSlackTaskStart, slackSlashTaskRecoveryPayload, slackTaskStarterThreadId } from "./chat-channels/slack/task-start.js"; import { createGitHubWebhookIngress, GITHUB_RECOVERY_STATE_KEY, type GitHubIngressStageResult } from "./chat-channels/github/webhook-ingress.js"; import { createSlackSessionStop } from "./chat-channels/slack/session-stop.js"; import { createTelegramMaintenance } from "./chat-channels/telegram/maintenance.js"; import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "./chat-channels/runtime-types.js"; -import { createChatFailedRunRetry, type ChatFailedRetrySource as FailedChatRetrySource } from "./chat-channels/failed-run-retry.js"; +import { createChatFailedRunRetry } from "./chat-channels/failed-run-retry.js"; import { createChatPublicationReplacement } from "./chat-channels/publication-replacement.js"; import { createChatIdentityLinks } from "./chat-channels/identity-links.js"; import { createChatResourceReconciliation } from "./chat-channels/resource-reconciliation.js"; @@ -107,16 +112,16 @@ import { runtimeCanonicalOrigin } from "./cloud-runtime-identity.js"; import { takePhotonCompanion } from "./photon/attachments.js"; import { writePhotonCheckpoint } from "./photon/receiver.js"; import { PhotonState } from "./photon/state.js"; -import { nativeSha256 } from "./native-runtime/canonical.js"; + import { HEIF_CONTENT_TYPES, photonHeifPreview, validatePhotonImage } from "./photon/media.js"; import { projectSafeChatPublicationText } from "./chat-publication-projection.js"; -import { PhotonAnswerValidationError, nativePhotonInteraction, publishPhotonPrompt, photonResponseCommand, parsePhotonQuestionAnswer, type PhotonPromptReceipt, type PhotonInteractionBinding, type PhotonDraft } from "./photon/interactions.js"; -import { validateNativeQuestionResponseInput } from "./native-runtime/native-question-bridge.js"; -import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction } from "@paperclipai/shared"; -import { PhotonCloudClient, PhotonError, photonFailure, photonSharedIdentity, photonSharedScope } from "./photon/cloud.js"; -import { PhotonChatAdapter, photonThreadId, photonReplyReference } from "./photon/adapter.js"; +import { nativePhotonInteraction, publishPhotonPrompt, type PhotonPromptReceipt, type PhotonInteractionBinding } from "./photon/interactions.js"; + + +import { PhotonCloudClient, PhotonError, photonSharedIdentity, photonSharedScope } from "./photon/cloud.js"; +import { PhotonChatAdapter, photonReplyReference } from "./photon/adapter.js"; import { photonChannelConfigurationSchema } from "@paperclipai/shared"; -import type { LiveEvent as PhotonEvent } from "@photon-ai/advanced-imessage"; + import { defaultSlackAppConfiguration } from "@paperclipai/shared"; import { createHash, @@ -221,9 +226,7 @@ import { } from "../attachment-types.js"; import { isUniqueViolation } from "../db-errors.js"; import { - bindTeamsPersonalRecipient, deriveTeamsPersonalRecipient, - parseTeamsPersonalRecipient, } from "./chat-teams-personal-recipient.js"; import { teamsFileTransferService, @@ -253,15 +256,8 @@ import { } from "./chat-telegram-media-intake.js"; import { normalizeTelegramRichMessage } from "./chat-telegram-rich-intake.js"; import { - TELEGRAM_DRAFT_ACTION_KIND, - TELEGRAM_DRAFT_STOPPED_REASON, isTelegramDraftStopped, - parseTelegramDraftBinding, - telegramDraftTextSha256, - telegramGenerationStoppedReceipt, - telegramPrivateDraftDestination, type TelegramDraftControl, - type TelegramGenerationStoppedProof, } from "./chat-telegram-draft-stop.js"; @@ -354,11 +350,8 @@ import { } from "./chat-discord-question-forms.js"; import { parseSlackSessionStop, - setSlackSessionStatus, - slackSessionStatusForPublication, } from "./chat-slack-sessions.js"; import { - slackSessionSyncPayload, stageSlackSessionSync, } from "./chat-slack-session-outbox.js"; import { @@ -859,9 +852,6 @@ const REACTION_LINK_MAX_AGE_MS = 2 * 60_000; const DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS = 5 * 60_000; const DELIVERY_LEASE_TTL_MS = 90_000; const DELIVERY_DRAIN_LIMIT = 100; -const SLACK_FILE_RECEIPT_MAX_AGE_MS = 24 * 60 * 60_000; -const SLACK_FILE_RECEIPT_MAX_ATTEMPTS = 12; -const SLACK_FILE_RECEIPT_STALE_MS = 60_000; const ORPHAN_FOLLOW_UP_GRACE_MS = 5_000; const ORPHAN_FOLLOW_UP_MAX_ATTEMPTS = 12; const PUBLICATION_ENDPOINT_CONCURRENCY = 4; @@ -1082,81 +1072,6 @@ type ProviderEffectPayload = { credentialFingerprint: string; }; -type SlackFileUploadReceiptPayload = { - version: 1; - publicationId: string; - publicationAttempt: number; - threadId: string; - fileIds: string[]; - botExternalId: string; - runtimeGeneration: number; - credentialFingerprint: string; -}; - -function slackFileUploadReceiptPayload( - payload: Record, -): SlackFileUploadReceiptPayload | null { - if ( - payload.version !== 1 || - typeof payload.publicationId !== "string" || - !isUuidLike(payload.publicationId) || - typeof payload.publicationAttempt !== "number" || - !Number.isSafeInteger(payload.publicationAttempt) || - payload.publicationAttempt < 1 || - typeof payload.threadId !== "string" || - payload.threadId.length < 1 || - payload.threadId.length > 2_048 || - !payload.threadId.startsWith("slack:") || - typeof payload.botExternalId !== "string" || - payload.botExternalId.length < 1 || - payload.botExternalId.length > 255 || - typeof payload.runtimeGeneration !== "number" || - !Number.isSafeInteger(payload.runtimeGeneration) || - payload.runtimeGeneration < 0 || - typeof payload.credentialFingerprint !== "string" || - !/^[a-f0-9]{64}$/.test(payload.credentialFingerprint) || - !Array.isArray(payload.fileIds) || - payload.fileIds.length < 1 || - payload.fileIds.length > 20 || - payload.fileIds.some( - (fileId) => - typeof fileId !== "string" || !/^F[A-Z0-9]{1,254}$/.test(fileId), - ) || - new Set(payload.fileIds).size !== payload.fileIds.length - ) { - return null; - } - return payload as SlackFileUploadReceiptPayload; -} - -function operatorConfirmedSlackFileReceipt( - result: Record | null, - payload: SlackFileUploadReceiptPayload, -): boolean { - return ( - result?.operatorConfirmedPublicationId === payload.publicationId && - result.operatorConfirmedAttempt === payload.publicationAttempt && - typeof result.operatorConfirmedAt === "string" && - Number.isFinite(Date.parse(result.operatorConfirmedAt)) - ); -} - -function sameSlackFileUploadReceipt( - left: SlackFileUploadReceiptPayload, - right: SlackFileUploadReceiptPayload, -): boolean { - return ( - left.publicationId === right.publicationId && - left.publicationAttempt === right.publicationAttempt && - left.threadId === right.threadId && - left.botExternalId === right.botExternalId && - left.runtimeGeneration === right.runtimeGeneration && - left.credentialFingerprint === right.credentialFingerprint && - left.fileIds.length === right.fileIds.length && - left.fileIds.every((fileId, index) => fileId === right.fileIds[index]) - ); -} - function isSlackReceiptReactionAlreadyApplied(error: unknown): boolean { const pending: Array<{ depth: number; value: unknown }> = [ { depth: 0, value: error }, @@ -5348,6 +5263,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } const withCredentialMutationLease = chatCredentialMutationLease(db, options); + const { handlePhotonFailure, handlePhotonEvent } = createPhotonEvents(db, { runtimeCallbackRecord, runtimeCallbackEndpoint, invalidateRuntime, ensurePrincipal, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications, questionResponses, redactError, renewDiscordGatewayOwnershipForMessageAdmission, handleSdkMessage }); + const { handleTelegramGenerationStopped, reserveTelegramPublicationDraft } = createTelegramPublicationDrafts(db, { runtimeCallbackRecord, runtimeCallbackEndpoint, runtimeFor, runtime, runtimeContexts, credentialFingerprint, authorizeRetainedChatSourcePublication }); + const { recordSlackFileUploadReceipt, stageSlackFilePublicationSuccess, processPendingSlackFileUploadReceipts, nextSlackReceiptAt } = createSlackFileReceipts(db, { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runtimeFor, isExplicitOperatorPublication, recordExactOutboundPublicationLink }); + const { processPendingSlackSessionSyncs } = createSlackSessionStatus(db, { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runIdFromMilestonePublication, resolveCredentials, fetchImpl, redactError, get slackSessionSyncSelectionBarrier() { return options.slackSessionSyncSelectionBarrier; } }); const { reconcileDiscordCommands, handleDiscordNativeCommand } = createDiscordCommands(db, { endpointRecord, runtimeContextForRecord, runtimeGeneration, credentialFingerprint, resolveCredentialRefs, fetchImpl, withCredentialMutationLease, runtimeCallbackRecord, ensurePrincipal, runtimeCallbackEndpoint, nonDirectDestinationAllowed, lockCurrentPrincipalAuthorization, getDiscordGatewayOwnership: (endpointId) => discordGatewayOwnerships.get(endpointId), discordGatewayRuntimeIsCurrent, stageAuthorizedTaskControlPublication, scheduleMessageProcessing, processPendingPublications }); const { latestSlackDmControlThreadId, finalizeSlackTaskStartFailure, admitConfirmedSlackTaskStart, processSlackTaskStart, processPendingSlackTaskStarts } = createSlackTaskStart(db, { endpointRecord, runtimeGeneration, credentialFingerprint, invalidateRuntime, runtimeFor, processMessage, runtimeContextForRecord, redactError, withCredentialMutationLease, sponsorAllowsGuest, nonDirectDestinationAllowed, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, get slackTaskAdmissionClaimBarrier() { return options.slackTaskAdmissionClaimBarrier; }, maxErrorText: MAX_ERROR_TEXT }); const { stageGitHubWebhookIngress, githubIngressAcceptedResponse, processGitHubWebhookIngress, processFailedGitHubWebhookDeliveries, processPendingGitHubWebhookIngress } = createGitHubWebhookIngress(db, { endpointRecord, runtimeContextForRecord, resolveCredentialRefs, runtimeGeneration, credentialFingerprint, getWebhookPublicBaseUrl, redactError, handleWebhook, runtimeCallbackEndpoint, fetchImpl, persistence, withCredentialMutationLease, isShuttingDown: () => shuttingDown, get githubWebhookReplayBarrier() { return options.githubWebhookReplayBarrier; }, supportedWebhookEvents: SUPPORTED_GITHUB_WEBHOOK_EVENTS }); @@ -6294,829 +6213,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return inspection; } - async function handlePhotonFailure( - endpointId: string, - error: unknown, - context: RuntimeContext, - ) { - if (error instanceof PhotonError && error.code === "attachment_not_ready") - return; - error = photonFailure(error); - const fatal = - error instanceof PhotonError && - [ - "credentials", - "line_unavailable", - "history_gap", - "invalid_response", - ].includes(error.code); - const message = - error instanceof PhotonError - ? error.message - : "Photon connection interrupted; reconnecting"; - await db.transaction(async (tx) => { - const endpoint = await runtimeCallbackEndpoint(tx, endpointId, context, [ - "verifying", - "active", - "attention", - ]); - if (!endpoint) return; - await tx - .update(chatEndpoints) - .set({ - status: "attention", - healthMessage: message, - lastError: message, - updatedAt: new Date(), - }) - .where(eq(chatEndpoints.id, endpointId)); - if (fatal) - await tx - .update(toolConnections) - .set({ - enabled: false, - healthStatus: "error", - healthMessage: message, - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - }); - // Retirement is queued outside the receiver callback to avoid self-joining. - void invalidateRuntime(endpointId).catch(() => undefined); - } - - async function processPhotonResponse( - endpoint: EndpointRow, - event: PhotonEvent, - threadId: string, - adapter: PhotonChatAdapter, - context: RuntimeContext, - ): Promise { - const command = - event.type === "message.received" - ? photonResponseCommand(event.message.content.text ?? "") - : null; - const replyGuid = - event.type === "message.received" ? event.message.replyTargetGuid : null; - const prompt = - event.type === "poll.changed" - ? await adapter.state.read( - `poll-message:${event.pollMessageGuid}`, - ) - : replyGuid - ? ((await adapter.state.read( - `prompt-message:${replyGuid}`, - )) ?? - (await adapter.state.read( - `poll-message:${replyGuid}`, - ))) - : null; - const reference = command?.reference ?? prompt?.reference; - if (!reference) { - if ( - !event.isFromMe && - (replyGuid || - (event.type === "poll.changed" && - (event.delta.type === "voted" || event.delta.type === "unvoted"))) - ) { - // A vote can beat the local poll-binding write. Keep it behind the - // checkpoint while a publication in this chat is in flight. - const pending = await db - .select({ id: chatPublications.id }) - .from(chatPublications) - .innerJoin( - chatConversations, - eq(chatConversations.id, chatPublications.conversationId), - ) - .where( - and( - eq(chatPublications.endpointId, endpoint.id), - eq(chatConversations.externalThreadId, threadId), - inArray(chatPublications.state, [ - "pending", - "retry", - "streaming", - "delivery_unknown", - ]), - isNotNull(sql`${chatPublications.payload}->>'interactionId'`), - ), - ) - .limit(1); - if (pending.length) - throw new PhotonError( - "attachment_not_ready", - "Waiting for Photon poll binding", - ); - } - return ( - event.type === "poll.changed" || - (event.type === "message.received" && - /^\/(answer|submit)\b/i.test(event.message.content.text ?? "")) - ); - } - const action = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, endpoint.companyId), - eq(chatActions.endpointId, endpoint.id), - eq(chatActions.kind, "photon_interaction"), - eq(chatActions.providerActionId, `photon:${reference}`), - ), - ) - .then((rows) => rows[0]); - if (!action || action.status !== "issued" || !action.conversationId) - return true; - const binding = action.payload as unknown as PhotonInteractionBinding; - if ( - binding.version !== 1 || - binding.reference !== reference || - !Number.isFinite(Date.parse(binding.expiresAt)) || - Date.parse(binding.expiresAt) <= Date.now() - ) - return true; - const conversation = await db - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, action.conversationId), - ), - ) - .then((rows) => rows[0]); - if ( - !conversation || - !["active", "waiting"].includes(conversation.state) || - conversation.sessionGeneration !== binding.sessionGeneration || - conversation.externalThreadId !== threadId - ) - return true; - const publication = await db - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq(chatPublications.id, binding.publicationId), - eq(chatPublications.conversationId, conversation.id), - ), - ) - .then((rows) => rows[0]); - if ( - !publication || - publication.payload.interactionId !== binding.interactionId || - new Date(event.occurredAt).getTime() < action.createdAt.getTime() - ) - return true; - if ( - ["pending", "retry", "streaming", "delivery_unknown"].includes( - publication.state, - ) - ) - throw new PhotonError( - "attachment_not_ready", - "Waiting for Photon prompt publication", - ); - if (publication.state !== "published") return true; - const interaction = ( - await issueThreadInteractionService(db).listForIssue(conversation.issueId) - ).find((candidate) => candidate.id === binding.interactionId); - if ( - !interaction || - interaction.status !== "pending" || - interaction.companyId !== endpoint.companyId || - interaction.createdByAgentId !== endpoint.assignedAgentId || - !nativePhotonInteraction(interaction) - ) - return true; - const actor = - event.type === "message.received" ? event.message.sender : event.actor; - if (event.isFromMe || !actor?.address || actor.service !== "iMessage") - return true; - const chat = await adapter.chatInfo(threadId); - if ( - chat.isArchived || - !chat.participants.some( - (person) => - person.address === actor.address && person.service === actor.service, - ) - ) - return true; - const principal = await ensurePrincipal( - endpoint, - { - userId: `${actor.service}:${actor.address}`, - userName: actor.address, - fullName: actor.address, - isMe: false, - isBot: false, - }, - event, - ); - if ( - !principal.userId || - principal.linkedDenied || - principal.principal.kind !== "user" || - principal.principal.isBot - ) - return true; - const assertCurrent = async () => - db.transaction((tx) => - requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext: context, - }), - ); - try { - await assertCurrent(); - } catch (error) { - if (isExternalActionAuthorizationChange(error)) return true; - throw error; - } - const draftKey = `draft:${reference}:${principal.principal.id}:${principal.userId}`; - let draft = (await adapter.state.read(draftKey)) ?? { - schema: 1, - interactionId: interaction.id, - principalId: principal.principal.id, - userId: principal.userId, - answers: [], - lastSequence: 0, - }; - if (draft.lastSequence > event.sequence) return true; - const questionIndex = command?.questionIndex ?? prompt?.questionIndex ?? 0; - let answerValue = - command?.value ?? - (event.type === "message.received" - ? (event.message.content.text ?? "") - : ""); - let pollChoice: string | undefined; - if (event.type === "poll.changed") { - if (!prompt || event.pollMessageGuid !== prompt.pollMessageGuid) - return true; - if (event.delta.type !== "voted" && event.delta.type !== "unvoted") - return true; - pollChoice = prompt.options[event.delta.optionIdentifier]; - if (!pollChoice) return true; - if (event.delta.type === "unvoted") { - const questionId = - interaction.kind === "ask_user_questions" - ? interaction.payload.questions[questionIndex]?.id - : null; - await adapter.state.update(draftKey, () => ({ - ...draft, - answers: draft.answers.filter( - (answer) => - answer.questionId !== questionId || - !answer.optionIds.includes(pollChoice!), - ), - decision: draft.decision === pollChoice ? undefined : draft.decision, - lastSequence: event.sequence, - })); - return true; - } - } - const enqueueResponse = async (key: string, text: string) => { - await assertCurrent(); - await db.transaction(async (tx) => { - await tx - .insert(chatActions) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - conversationId: conversation.id, - kind: "photon_response_notice", - providerActionId: `photon-notice:${key}`, - payload: { - reference, - questionIndex, - publicationId: binding.publicationId, - }, - status: "processed", - }) - .onConflictDoNothing(); - await notifyChatPublicationWork(tx); - await tx - .insert(chatPublications) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - conversationId: conversation.id, - issueId: conversation.issueId, - idempotencyKey: key, - payload: { - ...publication.payload, - card: undefined, - text: projectSafeChatPublicationText(text), - }, - state: "pending", - }) - .onConflictDoNothing(); - }); - scheduleMessageProcessing(() => - processPendingPublications().then(() => undefined), - ); - }; - const notice = (text: string) => - enqueueResponse(`photon-response:${reference}:${event.sequence}`, text); - try { - if (interaction.kind === "ask_user_questions") { - if (command?.command !== "submit") { - let answer: AskUserQuestionsAnswer; - if (pollChoice) { - const question = interaction.payload.questions[questionIndex]; - if ( - !question || - !question.options.some((option) => option.id === pollChoice) - ) - return true; - answer = { questionId: question.id, optionIds: [pollChoice] }; - } else - answer = parsePhotonQuestionAnswer( - interaction, - questionIndex, - answerValue, - ); - draft = { - ...draft, - answers: [ - ...draft.answers.filter( - (entry) => entry.questionId !== answer.questionId, - ), - answer, - ], - lastSequence: event.sequence, - }; - } - const answers = interaction.payload.questions.flatMap((question) => - draft.answers.filter((answer) => answer.questionId === question.id), - ); - if ( - interaction.payload.questions.length > 1 && - command?.command !== "submit" - ) { - await adapter.state.update(draftKey, () => draft); - const next = interaction.payload.questions.findIndex( - (question) => - !answers.some((answer) => answer.questionId === question.id), - ); - if (next >= 0) - await enqueueResponse( - `photon-question:${reference}:${next}`, - "Input needed", - ); - else - await notice( - `Your answers are saved. Send /submit ${reference} to submit them.`, - ); - return true; - } - await validateNativeQuestionResponseInput(interaction, { answers }); - // Canonical service rechecks required fields, options, audience, and - // pending -> answered atomically with the response-delivery receipt. - const issue = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .then((rows) => rows[0]); - if (!issue) return true; - const answered = await issueThreadInteractionService( - db, - ).answerQuestions( - issue, - interaction.id, - { answers }, - { userId: principal.userId }, - { - beforeResolveInTransaction: async (tx) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext: context, - }); - const current = await tx - .select() - .from(chatConversations) - .where(eq(chatConversations.id, conversation.id)) - .then((rows) => rows[0]); - if ( - current?.sessionGeneration !== binding.sessionGeneration || - Date.parse(binding.expiresAt) <= Date.now() - ) - throw forbidden( - "Photon prompt expired or its task generation changed", - ); - }, - afterResolveInTransaction: async (tx, resolved) => { - const claimed = await tx - .update(chatActions) - .set({ - principalId: principal.principal.id, - status: "processed", - result: { - code: "photon_question_answered", - interactionId: resolved.id, - interactionStatus: resolved.status, - answersSha256: nativeSha256( - (resolved as AskUserQuestionsInteraction).result?.answers, - ), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "issued"), - ), - ) - .returning({ id: chatActions.id }); - if (!claimed.length) - throw forbidden("Photon prompt has already been resolved"); - await logActivity(tx as unknown as Db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: principal.userId!, - action: "issue.thread_interaction_answered", - entityType: "issue", - entityId: issue.id, - details: { - source: "external_chat", - provider: endpoint.provider, - endpointId: endpoint.id, - conversationId: conversation.id, - interactionId: resolved.id, - }, - }); - }, - }, - ); - scheduleMessageProcessing(async () => { - await questionResponses.deliver(answered.id); - await processPendingPublications(); - }); - } else { - if (command?.command === "submit") return true; - const matched = /^(accept|reject|1|2)(?:\s+([\s\S]+))?$/i.exec( - answerValue.trim(), - ); - const decision = - pollChoice === "accept" || pollChoice === "reject" - ? pollChoice - : matched - ? /^(accept|1)$/i.test(matched[1]) - ? "accept" - : "reject" - : draft.decision; - if (!decision) - throw new PhotonAnswerValidationError( - `Send /answer ${reference} Accept or /answer ${reference} Reject .`, - ); - const reason = - matched?.[2]?.trim() ?? - (draft.decision === "reject" && !pollChoice - ? answerValue.trim() - : ""); - if ( - decision === "reject" && - interaction.payload.rejectRequiresReason && - !reason - ) { - await adapter.state.update(draftKey, () => ({ - ...draft, - decision, - lastSequence: event.sequence, - })); - await notice( - `Give a rejection reason with /answer ${reference} Reject .`, - ); - return true; - } - const issue = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .then((rows) => rows[0]); - if (!issue) return true; - const mutation = { - beforeResolveInTransaction: async (tx: DbTransaction) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext: context, - }); - const current = await tx - .select() - .from(chatConversations) - .where(eq(chatConversations.id, conversation.id)) - .then((rows) => rows[0]); - if ( - current?.sessionGeneration !== binding.sessionGeneration || - Date.parse(binding.expiresAt) <= Date.now() - ) - throw forbidden( - "Photon prompt expired or its task generation changed", - ); - }, - afterResolveInTransaction: async ( - tx: DbTransaction, - resolved: IssueThreadInteraction, - ) => { - const claimed = await tx - .update(chatActions) - .set({ - principalId: principal.principal.id, - status: "processed", - result: { - code: "photon_confirmation_resolved", - interactionId: resolved.id, - interactionStatus: resolved.status, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "issued"), - ), - ) - .returning({ id: chatActions.id }); - if (!claimed.length) - throw forbidden("Photon prompt has already been resolved"); - await logActivity(tx as unknown as Db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: principal.userId!, - action: - decision === "accept" - ? "issue.thread_interaction_accepted" - : "issue.thread_interaction_rejected", - entityType: "issue", - entityId: issue.id, - details: { - source: "external_chat", - provider: endpoint.provider, - endpointId: endpoint.id, - conversationId: conversation.id, - interactionId: resolved.id, - }, - }); - }, - }; - if (decision === "accept") - await issueThreadInteractionService(db).acceptInteraction( - issue, - interaction.id, - {}, - { userId: principal.userId }, - mutation, - ); - else - await issueThreadInteractionService(db).rejectInteraction( - issue, - interaction.id, - { reason }, - { userId: principal.userId }, - mutation, - ); - scheduleMessageProcessing(async () => { - await processPendingPublications(); - }); - } - } catch (error) { - if (error instanceof PhotonError) throw error; - if (isExternalActionAuthorizationChange(error)) return true; - if (error instanceof HttpError && [403, 404, 409].includes(error.status)) - return true; - // Only validator errors become correction copy. Infrastructure errors - // leave the checkpoint unchanged and retry through durable recovery. - if ( - (error instanceof HttpError && error.status === 422) || - error instanceof PhotonAnswerValidationError - ) { - const missingIndex = interaction.kind === "ask_user_questions" && command?.command === "submit" - ? interaction.payload.questions.findIndex((question) => - question.required !== false && !draft.answers.some((answer) => answer.questionId === question.id)) - : -1; - await notice( - `${redactError(error)} Send /answer ${reference}.${(missingIndex >= 0 ? missingIndex : questionIndex) + 1} to correct it.`, - ); - return true; - } - throw error; - } - return true; - } - - async function handlePhotonEvent( - endpointId: string, - event: PhotonEvent, - context: RuntimeContext, - ) { - const record = await runtimeCallbackRecord(endpointId, context, [ - "verifying", - "active", - "attention", - ]); - if (!record) - throw new Error("Photon receiver no longer owns this endpoint"); - const adapter = context.endpointRuntime!.getProviderAdapter(); - if (!(adapter instanceof PhotonChatAdapter)) - throw new Error("Photon adapter unavailable"); - if (adapter.authentication.identity.allocation === "shared" && event.type === "group.changed") return; - if ( - event.type === "group.changed" && - (event.change.type === "participantRemoved" || - event.change.type === "participantLeft") && - event.change.participant.address === record.endpoint.botExternalId - ) { - const removedThreadId = photonThreadId({ - lineId: adapter.authentication.identity.lineId, - chatGuid: event.chatGuid, - isGroup: true, - }); - await db.transaction(async (tx) => { - if ( - !(await runtimeCallbackEndpoint(tx, endpointId, context, [ - "verifying", - "active", - ])) - ) - throw new Error("Photon receiver is no longer current"); - if ( - ( - await renewDiscordGatewayOwnershipForMessageAdmission( - tx, - endpointId, - context, - ) - ).kind !== "owned" - ) - throw new Error("Photon receiver lease was replaced"); - // Removal can be the first retained event after activation. Preserve a - // tombstone even when the group has never been discovered locally. - await tx - .insert(chatEndpointResources) - .values({ - companyId: record.endpoint.companyId, - endpointId, - providerResourceId: removedThreadId, - type: "group_chat", - label: "Unavailable iMessage group", - enabled: false, - availability: "unavailable", - metadata: { photonRemoved: true }, - }) - .onConflictDoUpdate({ - target: [ - chatEndpointResources.endpointId, - chatEndpointResources.type, - chatEndpointResources.providerResourceId, - ], - set: { - availability: "unavailable", - metadata: sql`jsonb_set(${chatEndpointResources.metadata}, '{photonRemoved}', 'true'::jsonb)`, - updatedAt: new Date(), - }, - }); - }); - await adapter.endTyping(removedThreadId); - return; - } - const groupIdentity = photonThreadId({ lineId: adapter.authentication.identity.lineId, chatGuid: event.chatGuid, isGroup: true }); - const removedGroup = await db.select({ metadata: chatEndpointResources.metadata }).from(chatEndpointResources).where(and(eq(chatEndpointResources.companyId, record.endpoint.companyId), eq(chatEndpointResources.endpointId, endpointId), eq(chatEndpointResources.providerResourceId, groupIdentity))).limit(1).then((rows) => rows[0]); - const readded = event.type === "group.changed" && event.change.type === "participantAdded" && event.change.participant.address === record.endpoint.botExternalId; - // Late events from a removed chat must still advance recovery without a - // now-forbidden chat lookup holding every other conversation behind them. - if (removedGroup?.metadata.photonRemoved === true && !readded) return; - const chat = await adapter.client.chats - .get(event.chatGuid) - .catch((error) => { - throw photonFailure(error); - }); - if (chat.guid !== event.chatGuid || chat.service !== "iMessage" || (chat.isGroup && adapter.authentication.identity.allocation === "shared")) return; - const threadId = photonThreadId({ - lineId: adapter.authentication.identity.lineId, - chatGuid: chat.guid, - isGroup: chat.isGroup, - }); - const removed = - event.type === "group.changed" && - (event.change.type === "participantRemoved" || - event.change.type === "participantLeft") && - event.change.participant.address === record.endpoint.botExternalId; - const available = await db.transaction(async (tx) => { - if ( - !(await runtimeCallbackEndpoint(tx, endpointId, context, [ - "verifying", - "active", - ])) - ) - throw new Error("Photon receiver is no longer current"); - const ownership = await renewDiscordGatewayOwnershipForMessageAdmission( - tx, - endpointId, - context, - ); - if (ownership.kind !== "owned") - throw new Error("Photon receiver lease was replaced"); - const existing = await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, endpointId), - eq(chatEndpointResources.providerResourceId, threadId), - eq( - chatEndpointResources.type, - chat.isGroup ? "group_chat" : "direct_message", - ), - ), - ) - .then((rows) => rows[0]); - const added = - event.type === "group.changed" && - event.change.type === "participantAdded" && - event.change.participant.address === record.endpoint.botExternalId; - const stillRemoved = - removed || (existing?.metadata.photonRemoved === true && !added); - const availability = - stillRemoved || chat.isArchived ? "unavailable" : "available"; - const metadata = { - photonRemoved: stillRemoved, - participants: chat.participants - .slice(0, 256) - .map((p) => ({ address: p.address, service: p.service })), - }; - const label = ( - chat.displayName || chat.participants.map((p) => p.address).join(", ") - ).slice(0, 512); - await tx - .insert(chatEndpointResources) - .values({ - companyId: record.endpoint.companyId, - endpointId, - providerResourceId: threadId, - type: chat.isGroup ? "group_chat" : "direct_message", - label, - enabled: !chat.isGroup, - availability, - metadata, - }) - .onConflictDoUpdate({ - target: [ - chatEndpointResources.endpointId, - chatEndpointResources.type, - chatEndpointResources.providerResourceId, - ], - set: { label, availability, metadata, updatedAt: new Date() }, - }); - return availability === "available"; - }); - if (!available) return; - if ( - !event.isFromMe && - (await processPhotonResponse( - record.endpoint, - event, - threadId, - adapter, - context, - )) - ) - return; - if (event.type === "message.received" && !event.isFromMe) { - await handleSdkMessage( - { - provider: "imessage-photon", - endpointId, - thread: context.endpointRuntime!.thread(threadId), - message: adapter.normalize(event.message, chat), - trigger: chat.isGroup ? "unaddressed_message" : "direct_message", - }, - context, - ); - } - } - async function configure( endpointId: string, input: ConfigureChatEndpointInput, @@ -9956,6 +9052,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { wakeup: (...args) => options.heartbeat.wakeup(...args), staleProcessingMs: DELIVERY_PROCESSING_STALE_MS, }); + const { stageTeamsBoardFileIntent, deriveTeamsFileTransferAuthority } = createTeamsFileAuthority({ runtimeGeneration, credentialFingerprint, runtime, runtimeContexts, localRuntimeEpoch, runtimeVersions, lockCurrentPrincipalAuthorization, lifecycleRuntimeFence, assertFailedRetryReceipt, inboundWakeScope, failedChatRetrySource, isExplicitOperatorPublication, retryCommentIds }); async function authorizeRetainedChatSourcePublication( tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect, @@ -23216,943 +22313,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - // Native Teams file consent is authority for one personal recipient and one - // immutable file, not authority inferred from the SDK's latest route cache. - const teamsBoardFileIntentKind = "teams_board_file_intent"; - const teamsFileProofHash = (value: unknown) => - createHash("sha256") - .update( - JSON.stringify(value, (_key, entry) => - entry && typeof entry === "object" && !Array.isArray(entry) - ? Object.fromEntries( - Object.keys(entry) - .sort() - .map((key) => [key, entry[key]]), - ) - : entry, - ), - ) - .digest("hex"); - - async function teamsFileCurrentScope( - tx: DbTransaction, - supplied: typeof chatPublications.$inferSelect, - fence?: LifecycleRuntimeFence, - ) { - const [endpoint] = await tx - .select() - .from(chatEndpoints) - .where( - and( - eq(chatEndpoints.companyId, supplied.companyId), - eq(chatEndpoints.id, supplied.endpointId), - ), - ) - .for("no key update", { noWait: true }); - if ( - !endpoint || - endpoint.provider !== "microsoft-teams" || - endpoint.status !== "active" || - !endpoint.allowDirectMessages || - !isUuidLike(endpoint.providerAccountId) || - !isUuidLike(endpoint.botExternalId) - ) - return null; - const [connection] = await tx - .select() - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .for("share", { noWait: true }); - if (!connection?.enabled || connection.status !== "active") return null; - const currentFence = { - generation: runtimeGeneration(endpoint.setup), - credentialFingerprint: credentialFingerprint( - connection.credentialSecretRefs, - ), - }; - const instance = runtime.get(endpoint.id); - const registration = instance - ? runtimeContexts.get(instance as object) - : null; - if ( - !instance || - !registration || - registration.generation !== currentFence.generation || - registration.credentialFingerprint !== - currentFence.credentialFingerprint || - registration.localEpoch !== localRuntimeEpoch(endpoint.id) || - runtimeVersions.get(endpoint.id) !== registration.version || - (fence && - (fence.generation !== currentFence.generation || - fence.credentialFingerprint !== currentFence.credentialFingerprint)) - ) - return null; - const [conversation] = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, supplied.conversationId), - eq(chatConversations.issueId, supplied.issueId), - ), - ) - .for("update", { noWait: true }); - if ( - !conversation?.isDirectMessage || - !["active", "waiting"].includes(conversation.state) - ) - return null; - const [superseded] = await tx - .select({ id: chatConversations.id }) - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.externalThreadId, conversation.externalThreadId), - gt( - chatConversations.sessionGeneration, - conversation.sessionGeneration, - ), - ), - ) - .limit(1); - if (superseded) return null; - const [issue] = await tx - .select({ id: issues.id, assigneeAgentId: issues.assigneeAgentId }) - .from(issues) - .where( - and( - eq(issues.companyId, endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .for("share", { noWait: true }); - if (!issue || issue.assigneeAgentId !== endpoint.assignedAgentId) - return null; - // The conversation lock serializes rollover. The endpoint lock serializes - // reach/credential changes; no lock here waits behind inverse Board order. - if (conversation.resourceId) - await tx - .select({ id: chatEndpointResources.id }) - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.id, conversation.resourceId), - ), - ) - .for("update", { noWait: true }); - const [publication] = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq(chatPublications.conversationId, conversation.id), - eq(chatPublications.issueId, conversation.issueId), - eq(chatPublications.id, supplied.id), - ), - ) - .for("update", { noWait: true }); - const ids = publication?.payload.attachmentIds; - if ( - !publication?.commentId || - !Array.isArray(ids) || - ids.length !== 1 || - !isUuidLike(ids[0]) || - publication.payload.progressState !== undefined || - publication.payload.interactionId || - publication.payload.card || - publication.idempotencyKey.startsWith("control:") || - publication.idempotencyKey.startsWith("wake:") - ) - return null; - const [comment] = await tx - .select() - .from(issueComments) - .where( - and( - eq(issueComments.companyId, endpoint.companyId), - eq(issueComments.issueId, conversation.issueId), - eq(issueComments.id, publication.commentId), - isNull(issueComments.deletedAt), - ), - ) - .for("share", { noWait: true }); - const [file] = await tx - .select({ attachment: issueAttachments, asset: assets }) - .from(issueAttachments) - .innerJoin( - assets, - and( - eq(assets.id, issueAttachments.assetId), - eq(assets.companyId, endpoint.companyId), - ), - ) - .where( - and( - eq(issueAttachments.companyId, endpoint.companyId), - eq(issueAttachments.issueId, conversation.issueId), - eq(issueAttachments.issueCommentId, publication.commentId), - eq(issueAttachments.id, ids[0]), - ), - ) - .for("share", { noWait: true }); - const filename = - file?.asset.originalFilename ?? - (file ? `attachment-${file.attachment.id}` : ""); - if ( - !comment || - comment.updatedAt.getTime() !== comment.createdAt.getTime() || - !file || - !Number.isSafeInteger(file.asset.byteSize) || - file.asset.byteSize <= 0 || - file.asset.byteSize > MAX_ATTACHMENT_BYTES || - file.asset.byteSize >= 60 * 1024 * 1024 || - !/^[a-f0-9]{64}$/.test(file.asset.sha256) || - !file.asset.objectKey.trim() || - !isAllowedContentType(file.asset.contentType) || - !filename || - filename.length > 255 || - filename !== filename.trim() || - filename.endsWith(".") || - /[<>:"/\\|?*\x00-\x1f\x7f]/.test(filename) - ) - return null; - const byteIdentity = { - attachmentId: file.attachment.id, - assetId: file.asset.id, - sha256: file.asset.sha256, - byteSize: file.asset.byteSize, - filename, - contentType: file.asset.contentType, - originatingRunId: file.attachment.originatingRunId, - }; - return { - endpoint, - conversation, - publication, - comment, - file, - filename, - currentFence, - byteIdentity, - instance, - }; - } - - async function teamsFilePrincipalAuthorization( - tx: DbTransaction, - endpoint: EndpointRow, - principalId: string, - ) { - const identity = await tx.execute( - sql`select pg_try_advisory_xact_lock(hashtextextended(${`chat-identity:${endpoint.companyId}:${principalId}`}, 0)) as locked`, - ); - if (!identity[0]?.locked) - throw new NativeChatReviewPresentationContentionError(); - const [link] = await tx - .select() - .from(chatIdentityLinks) - .where( - and( - eq(chatIdentityLinks.companyId, endpoint.companyId), - eq(chatIdentityLinks.endpointId, endpoint.id), - eq(chatIdentityLinks.principalId, principalId), - ), - ) - .for("update", { noWait: true }); - const userId = - link?.status === "linked" ? link.paperclipUserId : endpoint.sponsorUserId; - if (userId) - await tx - .select() - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, endpoint.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, userId), - ), - ) - .for("update", { noWait: true }); - // All rows and the identity advisory key used by the common authorization - // helper are already held; the helper cannot introduce an inverse wait. - return lockCurrentPrincipalAuthorization(tx, endpoint, principalId); - } - - async function teamsFileSourceBinding( - tx: DbTransaction, - scope: NonNullable>>, - actionId: string, - ) { - const { endpoint, conversation } = scope; - const [action] = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, endpoint.companyId), - eq(chatActions.endpointId, endpoint.id), - eq(chatActions.conversationId, conversation.id), - eq(chatActions.id, actionId), - eq(chatActions.kind, "inbound_wakeup"), - eq(chatActions.status, "processed"), - ), - ) - .for("update", { noWait: true }); - if ( - !action?.deliveryId || - !action.principalId || - action.payload.version !== 1 || - action.payload.issueId !== conversation.issueId || - action.payload.agentId !== endpoint.assignedAgentId || - action.payload.sessionGeneration !== conversation.sessionGeneration || - typeof action.payload.commentId !== "string" || - !isUuidLike(action.payload.commentId) - ) - return null; - const [delivery] = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, endpoint.companyId), - eq(chatDeliveries.endpointId, endpoint.id), - eq(chatDeliveries.conversationId, conversation.id), - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.principalId, action.principalId), - eq(chatDeliveries.state, "processed"), - inArray(chatDeliveries.eventKind, [ - "direct_message", - "mention", - "message", - ]), - ), - ) - .for("update", { noWait: true }); - const originalFence = delivery ? lifecycleRuntimeFence(delivery) : null; - const [principal] = await tx - .select() - .from(chatExternalPrincipals) - .where( - and( - eq(chatExternalPrincipals.companyId, endpoint.companyId), - eq(chatExternalPrincipals.id, action.principalId), - eq(chatExternalPrincipals.provider, "microsoft-teams"), - eq( - chatExternalPrincipals.providerAccountId, - endpoint.providerAccountId!, - ), - eq(chatExternalPrincipals.kind, "user"), - eq(chatExternalPrincipals.isBot, false), - ), - ) - .for("share", { noWait: true }); - if (!delivery || !originalFence || !principal) return null; - const admission = { - companyId: endpoint.companyId, - endpointId: endpoint.id, - runtimeGeneration: originalFence.generation, - credentialFingerprint: originalFence.credentialFingerprint, - tenantId: endpoint.providerAccountId!, - botAppId: endpoint.botExternalId!, - providerEventId: delivery.providerEventId, - threadId: conversation.externalThreadId, - isDirectMessage: true, - }; - const parsed = parseTeamsPersonalRecipient( - delivery.normalizedEvent.teamsPersonalRecipient, - admission, - ); - const binding = parsed - ? bindTeamsPersonalRecipient(parsed, { - admission, - deliveryId: delivery.id, - principalId: principal.id, - conversationId: conversation.id, - conversationGeneration: conversation.sessionGeneration, - externalPrincipalId: principal.externalId, - }) - : null; - const originalConversation = delivery.normalizedEvent.conversation as - | Record - | undefined; - const originalMessage = delivery.normalizedEvent.message as - | Record - | undefined; - const originalPrincipal = delivery.normalizedEvent.principal as - | Record - | undefined; - if ( - !binding || - originalConversation?.isDirectMessage !== true || - typeof originalConversation.externalThreadId !== "string" || - canonicalTeamsThreadId(originalConversation.externalThreadId) !== - canonicalTeamsThreadId(conversation.externalThreadId) || - originalMessage?.providerMessageId !== - binding.recipient.providerActivityId || - originalPrincipal?.externalId !== principal.externalId - ) - return null; - const [source] = await tx - .select({ comment: issueComments, link: chatMessageLinks }) - .from(chatMessageLinks) - .innerJoin( - issueComments, - and( - eq(issueComments.id, chatMessageLinks.commentId), - eq(issueComments.companyId, endpoint.companyId), - eq(issueComments.issueId, conversation.issueId), - ), - ) - .where( - and( - eq(chatMessageLinks.companyId, endpoint.companyId), - eq(chatMessageLinks.endpointId, endpoint.id), - eq(chatMessageLinks.conversationId, conversation.id), - eq(chatMessageLinks.deliveryId, delivery.id), - eq(chatMessageLinks.commentId, action.payload.commentId), - eq(chatMessageLinks.direction, "inbound"), - ), - ) - .for("share", { noWait: true }); - const expectedUserId = - action.payload.requestedByActorType === "user" - ? action.payload.requestedByActorId - : null; - if ( - !source || - source.link.providerMessageId !== binding.recipient.providerActivityId || - source.comment.deletedAt || - source.comment.createdAt > scope.comment.createdAt || - source.comment.updatedAt.getTime() !== - source.comment.createdAt.getTime() || - source.comment.authorUserId !== expectedUserId || - !["user", "system"].includes( - String(action.payload.requestedByActorType), - ) || - (expectedUserId === null && - action.payload.requestedByActorId !== principal.id) - ) - return null; - const [lifecycle] = await tx - .select({ id: chatDeliveries.id }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, endpoint.companyId), - eq(chatDeliveries.endpointId, endpoint.id), - or( - isNull(chatDeliveries.conversationId), - eq(chatDeliveries.conversationId, conversation.id), - ), - ne(chatDeliveries.state, "filtered"), - inArray(chatDeliveries.eventKind, [ - "message_updated", - "message_deleted", - "message_restored", - ]), - sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(originalFence)}::jsonb`, - sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${delivery.providerEventId}`, - ), - ) - .limit(1); - if (lifecycle) return null; - const authorization = await teamsFilePrincipalAuthorization( - tx, - endpoint, - principal.id, - ); - if (!authorization.allowed || authorization.userId !== expectedUserId) - return null; - const [receipt] = await tx - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, endpoint.companyId), - eq(agentWakeupRequests.id, action.id), - eq(agentWakeupRequests.agentId, endpoint.assignedAgentId), - ), - ) - .for("share", { noWait: true }); - if (!receipt || ["skipped", "cancelled"].includes(receipt.status)) - return null; - try { - assertFailedRetryReceipt( - createDurableChatWakeupRequest({ - id: action.id, - companyId: endpoint.companyId, - agentId: endpoint.assignedAgentId, - issueId: conversation.issueId, - commentId: source.comment.id, - requestedByActorType: action.payload.requestedByActorType as - | "user" - | "system", - requestedByActorId: String(action.payload.requestedByActorId), - requestedAt: action.createdAt, - authorize: async () => {}, - }), - receipt, - ); - } catch (error) { - if ( - error instanceof HttpError && - (error.details as { code?: string } | undefined)?.code === - "chat_failed_run_retry_not_authorized" - ) - return null; - throw error; - } - return { - binding, - authorization, - action, - sourceFacts: { - actionId: action.id, - deliveryId: delivery.id, - commentId: source.comment.id, - principalId: principal.id, - scope: inboundWakeScope(action), - originalFence, - bodySha256: createHash("sha256") - .update(source.comment.body) - .digest("hex"), - receiptId: receipt.id, - recipientBindingSha256: teamsFileProofHash(binding), - }, - }; - } - - async function teamsBoardOriginBinding( - tx: DbTransaction, - scope: NonNullable>>, - ) { - // Pick the task's first admitted source, never the latest DM actor. If the - // durable task contains different recipient principals, consent is ambiguous. - const principals = await tx - .selectDistinct({ id: chatActions.principalId }) - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, "inbound_wakeup"), - eq(chatActions.status, "processed"), - sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, - ), - ) - .limit(2); - if (principals.length !== 1 || !principals[0]?.id) return null; - const [origin] = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, "inbound_wakeup"), - eq(chatActions.status, "processed"), - sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, - ), - ) - .orderBy(asc(chatActions.createdAt), asc(chatActions.id)) - .limit(1); - return origin ? teamsFileSourceBinding(tx, scope, origin.id) : null; - } - - async function teamsBoardAuthorIsCurrent( - tx: DbTransaction, - companyId: string, - userId: string, - ) { - const [membership] = await tx - .select() - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, userId), - ), - ) - .for("share", { noWait: true }); - return ( - membership?.status === "active" && membership.membershipRole !== "viewer" - ); - } - - function teamsBoardFileIntentPayload( - scope: NonNullable>>, - source: NonNullable>>, - userId: string, - ) { - return { - schema: "paperclip.teams.board-file-intent.v1", - publicationId: scope.publication.id, - issueId: scope.conversation.issueId, - commentId: scope.comment.id, - attachmentId: scope.file.attachment.id, - userId, - conversationGeneration: scope.conversation.sessionGeneration, - sourceActionId: source.action.id, - sourceScopeSha256: teamsFileProofHash(source.sourceFacts), - recipientBindingSha256: teamsFileProofHash(source.binding), - byteIdentitySha256: teamsFileProofHash(scope.byteIdentity), - commentBodySha256: createHash("sha256") - .update(scope.comment.body) - .digest("hex"), - }; - } - - function teamsFileRuntimeStillCurrent( - scope: NonNullable>>, - ) { - const registration = runtimeContexts.get(scope.instance as object); - return ( - runtime.get(scope.endpoint.id) === scope.instance && - registration?.generation === scope.currentFence.generation && - registration.credentialFingerprint === - scope.currentFence.credentialFingerprint && - registration.localEpoch === localRuntimeEpoch(scope.endpoint.id) && - runtimeVersions.get(scope.endpoint.id) === registration.version - ); - } - - async function stageTeamsBoardFileIntent( - tx: DbTransaction, - publication: typeof chatPublications.$inferSelect, - userId: string, - conversation: ConversationRow, - ): Promise { - if ( - !publication.idempotencyKey.startsWith("explicit-board:") || - !conversation.isDirectMessage - ) - return; - const scope = await teamsFileCurrentScope(tx, publication); - if ( - !scope || - scope.conversation.id !== conversation.id || - scope.conversation.sessionGeneration !== conversation.sessionGeneration || - scope.comment.authorUserId !== userId || - scope.comment.authorAgentId || - scope.comment.createdByRunId || - !(await teamsBoardAuthorIsCurrent(tx, scope.endpoint.companyId, userId)) - ) - return; - const source = await teamsBoardOriginBinding(tx, scope); - if (!source || !teamsFileRuntimeStillCurrent(scope)) return; - const key = `${teamsBoardFileIntentKind}:${publication.id}`; - const payload = teamsBoardFileIntentPayload(scope, source, userId); - const [existing] = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.providerActionId, key), - ), - ) - .for("update", { noWait: true }); - if (existing) { - if ( - existing.kind !== teamsBoardFileIntentKind || - existing.status !== "processed" || - existing.deliveryId !== source.binding.deliveryId || - existing.principalId !== source.binding.principalId || - existing.conversationId !== scope.conversation.id || - teamsFileProofHash(existing.payload) !== teamsFileProofHash(payload) - ) - throw conflict("The Teams file recipient intent has changed", { - code: "chat_file_transfer_authority_changed", - }); - return; - } - await tx.insert(chatActions).values({ - companyId: scope.endpoint.companyId, - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - deliveryId: source.binding.deliveryId, - principalId: source.binding.principalId, - kind: teamsBoardFileIntentKind, - providerActionId: key, - status: "processed", - payload, - }); - } - - async function deriveTeamsFileTransferAuthority( - tx: DbTransaction, - publication: typeof chatPublications.$inferSelect, - fence: LifecycleRuntimeFence, - continuingTeamsFileTransfer?: NonNullable< - NonNullable< - Parameters[1]["committedResponse"] - >["continuingTeamsFileTransfer"] - >, - ): Promise { - const scope = await teamsFileCurrentScope(tx, publication, fence); - if (!scope) return null; - let source: NonNullable>>; - let causal: Record; - if (isExplicitOperatorPublication(scope.publication)) { - if ( - !scope.publication.idempotencyKey.startsWith("explicit-board:") || - !scope.comment.authorUserId || - scope.comment.authorAgentId || - scope.comment.createdByRunId || - !(await teamsBoardAuthorIsCurrent( - tx, - scope.endpoint.companyId, - scope.comment.authorUserId, - )) - ) - return null; - const [intent] = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, teamsBoardFileIntentKind), - eq( - chatActions.providerActionId, - `${teamsBoardFileIntentKind}:${scope.publication.id}`, - ), - eq(chatActions.status, "processed"), - ), - ) - .for("share", { noWait: true }); - if ( - !intent || - typeof intent.payload.sourceActionId !== "string" || - !isUuidLike(intent.payload.sourceActionId) - ) - return null; - const bound = await teamsBoardOriginBinding(tx, scope); - if ( - !bound || - bound.action.id !== intent.payload.sourceActionId || - intent.deliveryId !== bound.binding.deliveryId || - intent.principalId !== bound.binding.principalId - ) - return null; - const currentPayload = teamsBoardFileIntentPayload( - scope, - bound, - scope.comment.authorUserId, - ); - if ( - teamsFileProofHash(intent.payload) !== - teamsFileProofHash(currentPayload) - ) - return null; - source = bound; - causal = { - kind: "explicit_board_send", - intentId: intent.id, - ...currentPayload, - }; - } else { - const runId = scope.comment.createdByRunId; - if ( - !runId || - scope.comment.authorAgentId !== scope.endpoint.assignedAgentId || - scope.file.attachment.originatingRunId !== runId - ) - return null; - const [run] = await tx - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, scope.endpoint.companyId), - eq(heartbeatRuns.id, runId), - eq(heartbeatRuns.agentId, scope.endpoint.assignedAgentId), - eq(heartbeatRuns.nativeIssueId, scope.conversation.issueId), - eq(heartbeatRuns.runtimeMode, "native"), - eq(heartbeatRuns.status, "succeeded"), - isNotNull(heartbeatRuns.finishedAt), - ), - ) - .for("share", { noWait: true }); - const marker = run?.resultJson?.nativeCommittedChatResponse as - | Record - | undefined; - if ( - !run || - !marker || - marker.schema !== "paperclip.native_committed_chat_response.v1" || - typeof marker.resultId !== "string" || - !isUuidLike(marker.resultId) || - typeof marker.decisionId !== "string" || - !isUuidLike(marker.decisionId) || - typeof marker.canonicalSha256 !== "string" || - !/^(?:sha256:)?[a-f0-9]{64}$/.test(marker.canonicalSha256) - ) - return null; - // Pre-lock the exact causal source and actor rows before the existing - // complete native publication proof invokes its common authorization. - const originActions = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, "inbound_wakeup"), - inArray( - sql`${chatActions.payload}->>'commentId'`, - retryCommentIds(run.contextSnapshot ?? {}), - ), - ), - ); - const bindings: Array< - NonNullable>> - > = []; - for (const action of originActions) { - const binding = await teamsFileSourceBinding(tx, scope, action.id); - if (!binding) return null; - bindings.push(binding); - } - let proved: FailedChatRetrySource; - try { - proved = await failedChatRetrySource(tx, { - companyId: scope.endpoint.companyId, - issueId: scope.conversation.issueId, - agentId: run.agentId, - failedRunId: run.id, - publication: true, - committedResponse: { - resultId: marker.resultId, - canonicalSha256: marker.canonicalSha256, - decisionId: marker.decisionId, - continuingTeamsFileTransfer, - }, - }); - } catch (error) { - if ( - error instanceof HttpError && - (error.details as { code?: string } | undefined)?.code === - "chat_failed_run_retry_not_authorized" - ) - return null; - throw error; - } - if ( - proved.provider !== "microsoft-teams" || - proved.endpointId !== scope.endpoint.id || - proved.conversationId !== scope.conversation.id || - proved.sessionGeneration !== scope.conversation.sessionGeneration || - bindings.length !== proved.sources.length - ) - return null; - const ordered = proved.sources.map((entry) => - bindings.find((binding) => binding.action.id === entry.actionId), - ); - const first = ordered[0]; - if ( - !first || - ordered.some( - (entry) => - !entry || - entry.binding.principalId !== proved.principalId || - entry.binding.recipient.providerConversationId !== - first.binding.recipient.providerConversationId || - entry.binding.recipient.providerUserId !== - first.binding.recipient.providerUserId || - entry.binding.recipient.aadObjectId !== - first.binding.recipient.aadObjectId, - ) - ) - return null; - if ( - run.resultJson?.finalizationReasonCode === - "governed_response_waiting" && - !(await authorizeNativeChatReviewPresentation( - tx as unknown as Db, - { - companyId: scope.endpoint.companyId, - issueId: scope.conversation.issueId, - runId: run.id, - resultJson: run.resultJson, - destination: { - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - }, - }, - "nonblocking", - )) - ) - return null; - source = first; - causal = { - kind: "native_committed_response", - runId: run.id, - resultId: marker.resultId, - canonicalSha256: marker.canonicalSha256, - decisionId: marker.decisionId, - sourceScopeSha256: proved.sourceScopeSha256, - sources: ordered.map((entry) => entry!.sourceFacts), - }; - } - // No mutable stage, attempt, receipt, route URL or provider token enters - // this digest. The next authorization derives these facts afresh. - const sourceDigest = teamsFileProofHash({ - schema: "paperclip.teams.file-source.v1", - companyId: scope.endpoint.companyId, - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - conversationGeneration: scope.conversation.sessionGeneration, - issueId: scope.conversation.issueId, - publicationId: scope.publication.id, - commentId: scope.comment.id, - causal, - byteIdentity: scope.byteIdentity, - }); - if (!teamsFileRuntimeStillCurrent(scope)) return null; - return { - companyId: scope.endpoint.companyId, - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - issueId: scope.conversation.issueId, - publicationId: scope.publication.id, - commentId: scope.comment.id, - attachmentId: scope.file.attachment.id, - principalId: source.binding.principalId, - authorizedUserId: source.authorization.userId, - runtimeGeneration: scope.currentFence.generation, - credentialFingerprint: scope.currentFence.credentialFingerprint, - conversationGeneration: scope.conversation.sessionGeneration, - sourceDigest, - tenantId: source.binding.recipient.tenantId, - botAppId: source.binding.recipient.botAppId, - aadObjectId: source.binding.recipient.aadObjectId, - providerConversationId: source.binding.recipient.providerConversationId, - providerUserId: source.binding.recipient.providerUserId, - sha256: scope.file.asset.sha256, - byteSize: scope.file.asset.byteSize, - filename: scope.filename, - }; - } - function teamsTransferProtocol( input: { credentialLease?: CredentialMutationLeaseGuard; @@ -24630,148 +22790,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }; } - async function recordSlackFileUploadReceipt(input: { - credentialLease: CredentialMutationLeaseGuard; - conversation: ConversationRow; - endpoint: EndpointRow; - publication: typeof chatPublications.$inferSelect; - receipt: SlackFileUploadAcceptedReceipt; - runtimeContext: LifecycleRuntimeFence; - }): Promise { - const publicationAttempt = input.publication.attempts + 1; - const botExternalId = input.endpoint.botExternalId; - const expectedThreadId = `slack:${input.receipt.channelId}:${input.receipt.threadTs ?? ""}`; - if ( - input.endpoint.provider !== "slack" || - !botExternalId || - input.receipt.version !== 1 || - expectedThreadId !== input.conversation.externalThreadId || - input.receipt.fileIds.length !== - (input.publication.payload.attachmentIds?.length ?? 0) - ) { - throw new Error( - "Slack file upload receipt did not match its publication", - ); - } - const payload: SlackFileUploadReceiptPayload = { - version: 1, - publicationId: input.publication.id, - publicationAttempt, - threadId: expectedThreadId, - fileIds: [...input.receipt.fileIds], - botExternalId, - runtimeGeneration: input.runtimeContext.generation, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - }; - if (!slackFileUploadReceiptPayload(payload)) { - throw new Error("Slack file upload receipt was malformed"); - } - const providerActionId = `slack-file-receipt:${input.publication.id}:${publicationAttempt}`; - const recordedAt = new Date(); - return db.transaction(async (tx) => { - await input.credentialLease.assertOwned(tx); - const publication = await tx - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - eq(chatPublications.id, input.publication.id), - eq(chatPublications.companyId, input.publication.companyId), - eq(chatPublications.endpointId, input.publication.endpointId), - eq( - chatPublications.conversationId, - input.publication.conversationId, - ), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, publicationAttempt), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const endpoint = publication - ? await runtimeCallbackEndpoint( - tx, - input.endpoint.id, - input.runtimeContext, - ["verifying", "active"], - ) - : null; - const conversation = endpoint - ? await tx - .select({ - externalThreadId: chatConversations.externalThreadId, - id: chatConversations.id, - }) - .from(chatConversations) - .where( - and( - eq(chatConversations.id, input.conversation.id), - eq(chatConversations.companyId, input.publication.companyId), - eq(chatConversations.endpointId, input.endpoint.id), - inArray(chatConversations.state, [ - "active", - "waiting", - "completed", - ]), - ), - ) - .for("no key update") - .then((rows) => rows[0] ?? null) - : null; - if ( - !publication || - endpoint?.provider !== "slack" || - endpoint.botExternalId !== botExternalId || - conversation?.externalThreadId !== expectedThreadId - ) { - throw new Error( - "Slack file upload ownership changed before receipt persistence", - ); - } - await notifyChatPublicationWork(tx); - const inserted = await tx - .insert(chatActions) - .values({ - companyId: input.publication.companyId, - endpointId: input.publication.endpointId, - conversationId: input.publication.conversationId, - kind: "slack_file_upload_receipt", - providerActionId, - payload, - status: "received", - result: { code: "slack_file_upload_identity_pending", attempts: 0 }, - createdAt: recordedAt, - updatedAt: recordedAt, - }) - .onConflictDoNothing() - .returning({ id: chatActions.id }); - if (inserted[0]) { - await input.credentialLease.assertOwned(tx); - return inserted[0].id; - } - const existing = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, input.publication.endpointId), - eq(chatActions.providerActionId, providerActionId), - eq(chatActions.kind, "slack_file_upload_receipt"), - sql`${chatActions.payload} = ${JSON.stringify(payload)}::jsonb`, - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!existing) { - throw new Error( - "Slack file upload receipt conflicted with durable state", - ); - } - await input.credentialLease.assertOwned(tx); - return existing.id; - }); - } - async function recordExactOutboundPublicationLink( tx: DbOrTransaction, input: { @@ -24813,447 +22831,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .then((rows) => rows.length > 0); } - async function stageSlackFilePublicationSuccess( - tx: DbTransaction, - input: { - committedAt: Date; - endpoint: EndpointRow; - publication: typeof chatPublications.$inferSelect; - providerMessageId: string; - runtimeContext: LifecycleRuntimeFence; - stageSessionSync: boolean; - }, - ): Promise { - if ( - !(await recordExactOutboundPublicationLink(tx, { - publication: input.publication, - providerMessageId: input.providerMessageId, - })) - ) { - throw new Error( - "Slack file upload message identity conflicts with another publication", - ); - } - await tx - .update(chatEndpoints) - .set({ - lastPublicationAt: input.committedAt, - updatedAt: input.committedAt, - }) - .where( - and( - eq(chatEndpoints.id, input.endpoint.id), - inArray(chatEndpoints.status, ["verifying", "active"]), - sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${input.runtimeContext.generation}`, - ), - ); - if (input.stageSessionSync) { - await stageSlackSessionSync(tx, { - companyId: input.publication.companyId, - endpointId: input.publication.endpointId, - conversationId: input.publication.conversationId, - runtimeGeneration: input.runtimeContext.generation, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - }); - } - } - - async function handleTelegramGenerationStopped( - callback: ChatSdkCallbackEvent, - context: RuntimeContext, - ): Promise { - const receipt = telegramGenerationStoppedReceipt(callback.event); - if (callback.provider !== "telegram" || !receipt) return; - const record = await runtimeCallbackRecord(callback.endpointId, context, [ - "verifying", - "active", - ]); - if ( - record?.endpoint.provider !== "telegram" || - record.endpoint.botExternalId !== receipt.botUserId - ) - return; - const candidates = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, record.endpoint.companyId), - eq(chatActions.endpointId, callback.endpointId), - eq(chatActions.kind, TELEGRAM_DRAFT_ACTION_KIND), - sql`${chatActions.payload}->>'botUserId' = ${receipt.botUserId}`, - sql`${chatActions.payload}->>'draftId' = ${String(receipt.draftId)}`, - ), - ) - .limit(2); - if (candidates.length !== 1) return; - const candidate = candidates[0]!; - const binding = parseTelegramDraftBinding(candidate.payload); - if ( - !binding || - binding.chatId !== receipt.chatId || - binding.messageThreadId !== receipt.messageThreadId || - binding.runtimeGeneration !== context.generation || - binding.credentialFingerprint !== context.credentialFingerprint - ) - return; - await db.transaction(async (tx) => { - // The sender holds a renewable credential lease during draft HTTP. Do - // not acquire that lease here: a Stop can arrive inside the first RPC. - // Both Stop and final-send use the publication→endpoint→draft lock order. - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, record.endpoint.companyId), - eq(chatPublications.endpointId, callback.endpointId), - eq(chatPublications.conversationId, binding.conversationId), - eq(chatPublications.id, binding.publicationId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const endpoint = await runtimeCallbackEndpoint( - tx, - callback.endpointId, - context, - ["verifying", "active"], - ); - if ( - !publication || - !endpoint || - endpoint.provider !== "telegram" || - endpoint.botExternalId !== receipt.botUserId || - runtime.get(endpoint.id) !== context.endpointRuntime || - publication.attempts !== binding.publicationAttempt || - telegramDraftTextSha256( - renderPublicationTransportText(publication.payload), - ) !== binding.textSha256 - ) - return; - const connection = await tx - .select() - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .for("share") - .then((rows) => rows[0] ?? null); - if ( - !connection?.enabled || - connection.status !== "active" || - credentialFingerprint(connection.credentialSecretRefs) !== - binding.credentialFingerprint - ) - return; - const conversation = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, binding.conversationId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - !conversation || - !conversation.isDirectMessage || - conversation.sessionGeneration !== binding.sessionGeneration || - conversation.externalThreadId !== - `telegram:${binding.chatId}${binding.messageThreadId === null ? "" : `:${binding.messageThreadId}`}` - ) - return; - const action = await tx - .select() - .from(chatActions) - .where(eq(chatActions.id, candidate.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - !action || - JSON.stringify(action.payload) !== JSON.stringify(candidate.payload) - ) - return; - // final_sending is an irreversible local I/O claim, not a delivery - // receipt. A late Stop must not claim that an in-flight final was undone. - if ( - action.result?.phase !== "drafting" || - action.status !== "processing" || - !["streaming", "delivery_unknown"].includes(publication.state) - ) - return; - const stoppedAt = new Date(); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - phase: "stopped", - updateId: receipt.updateId, - stoppedAt: stoppedAt.toISOString(), - }, - updatedAt: stoppedAt, - }) - .where(eq(chatActions.id, action.id)); - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ - state: "cancelled", - nextAttemptAt: null, - redactedError: TELEGRAM_DRAFT_STOPPED_REASON, - updatedAt: stoppedAt, - }) - .where(eq(chatPublications.id, publication.id)); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - code: "telegram_draft_presentation_stopped", - attempts: publication.attempts, - }, - updatedAt: stoppedAt, - }) - .where( - and( - eq(chatActions.companyId, publication.companyId), - eq(chatActions.endpointId, publication.endpointId), - eq( - chatActions.providerActionId, - `task-control-authorization:${publication.id}`, - ), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'attempts' = ${String(publication.attempts)}`, - ), - ); - }); - } - - async function reserveTelegramPublicationDraft(input: { - endpoint: EndpointRow; - conversation: ConversationRow; - publication: typeof chatPublications.$inferSelect; - text: string; - credentialLease: CredentialMutationLeaseGuard; - runtimeContext: RuntimeContext; - }): Promise<{ actionId: string; control: TelegramDraftControl }> { - const endpointRuntime = await runtimeFor(input.endpoint); - const registeredContext = runtimeContexts.get(endpointRuntime as object); - if ( - !registeredContext || - registeredContext.generation !== input.runtimeContext.generation || - registeredContext.credentialFingerprint !== - input.runtimeContext.credentialFingerprint || - registeredContext.version !== input.runtimeContext.version - ) - throw new Error("Telegram draft runtime authority changed"); - const draftRuntimeContext = { ...input.runtimeContext, endpointRuntime }; - const destination = telegramPrivateDraftDestination( - input.conversation.externalThreadId, - ); - if ( - !destination || - !input.conversation.isDirectMessage || - !input.endpoint.botExternalId - ) - throw new Error("Telegram private draft destination is invalid"); - const bindingBase = { - version: 1 as const, - publicationId: input.publication.id, - publicationAttempt: input.publication.attempts + 1, - conversationId: input.conversation.id, - sessionGeneration: input.conversation.sessionGeneration, - runtimeGeneration: input.runtimeContext.generation, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - botUserId: input.endpoint.botExternalId, - ...destination, - textSha256: telegramDraftTextSha256(input.text), - }; - const reserved = await db.transaction(async (tx) => { - await input.credentialLease.assertOwned(tx); - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.id, input.publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, bindingBase.publicationAttempt), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!publication) - throw new Error("Telegram draft publication ownership changed"); - const providerActionId = `telegram-draft-publication:${publication.id}:${publication.attempts}`; - const existing = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, input.endpoint.id), - eq(chatActions.providerActionId, providerActionId), - ), - ) - .limit(1); - // A second invocation of the same attempt cannot create another draft or - // replay an uncertain final. A new explicit publication attempt gets a - // fresh, never-before-used bot-global ID. - if (existing.length) - throw new Error("Telegram draft attempt already exists"); - // NO CYCLE and no company/endpoint ownership: rollback, endpoint deletion - // and bot re-binding cannot recycle a provider-visible draft identity. - // Exhaustion is a refusal, never a fallback to a process-local/random ID. - const allocated = await tx.execute( - sql`select nextval('chat_telegram_draft_ids')::integer as "draftId"`, - ); - const binding = parseTelegramDraftBinding({ - ...bindingBase, - draftId: allocated[0]?.draftId, - }); - if (!binding) - throw new Error("Telegram draft identity allocation was invalid"); - const [action] = await tx - .insert(chatActions) - .values({ - companyId: input.endpoint.companyId, - endpointId: input.endpoint.id, - conversationId: input.conversation.id, - kind: TELEGRAM_DRAFT_ACTION_KIND, - providerActionId, - payload: binding, - status: "issued", - result: { phase: "reserved" }, - }) - .returning(); - return { action: action!, binding }; - }); - const before = (final: boolean): Promise => - db.transaction(async (tx) => { - await input.credentialLease.assertOwned(tx); - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, input.endpoint.companyId), - eq(chatPublications.id, input.publication.id), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const endpoint = await runtimeCallbackEndpoint( - tx, - input.endpoint.id, - draftRuntimeContext, - ["verifying", "active"], - ); - if ( - !endpoint || - endpoint.provider !== "telegram" || - !endpoint.allowDirectMessages || - endpoint.botExternalId !== reserved.binding.botUserId || - runtime.get(endpoint.id) !== endpointRuntime - ) - throw new Error("Telegram draft endpoint authority changed"); - const connection = await tx - .select() - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .for("share") - .then((rows) => rows[0] ?? null); - if ( - !connection?.enabled || - connection.status !== "active" || - credentialFingerprint(connection.credentialSecretRefs) !== - reserved.binding.credentialFingerprint - ) - throw new Error("Telegram draft credential authority changed"); - const conversation = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, input.conversation.id), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const action = await tx - .select() - .from(chatActions) - .where(eq(chatActions.id, reserved.action.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - !action || - JSON.stringify(action.payload) !== - JSON.stringify(reserved.action.payload) || - !publication || - publication.attempts !== reserved.binding.publicationAttempt - ) - throw new Error("Telegram draft attempt authority changed"); - if ( - action.status === "cancelled" && - action.result?.phase === "stopped" && - publication.state === "cancelled" - ) - return false; - if ( - publication.state !== "streaming" || - !conversation || - telegramDraftTextSha256( - renderPublicationTransportText(publication.payload), - ) !== reserved.binding.textSha256 || - conversation.sessionGeneration !== - reserved.binding.sessionGeneration || - conversation.externalThreadId !== - input.conversation.externalThreadId || - !["active", "waiting", "completed"].includes(conversation.state) || - !["reserved", "drafting"].includes(String(action.result?.phase)) || - !["issued", "processing"].includes(action.status) || - !(await authorizeRetainedChatSourcePublication(tx, publication)) - ) - throw new Error("Telegram draft source authority changed"); - await tx - .update(chatActions) - .set({ - status: "processing", - result: { - phase: final ? "final_sending" : "drafting", - }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, action.id)); - await input.credentialLease.assertOwned(tx); - return true; - }); - return { - actionId: reserved.action.id, - control: { - version: 1, - draftId: reserved.binding.draftId, - beforeDraft: () => before(false), - beforeFinal: () => before(true), - }, - }; - } - async function postSafePublication(input: { endpoint: EndpointRow; conversation: ConversationRow; @@ -27056,967 +24633,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return result; } - async function lockSlackFileReceiptAuthorization( - tx: DbTransaction, - input: { - actionId: string; - payload: SlackFileUploadReceiptPayload; - }, - ): Promise< - | { - action: typeof chatActions.$inferSelect; - conversation: ConversationRow; - endpoint: EndpointRow; - mode: "delivery_unknown" | "operator_confirmed"; - publication: typeof chatPublications.$inferSelect; - } - | { action: typeof chatActions.$inferSelect; mode: "defer" | "settled" } - | null - > { - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.id, input.payload.publicationId), - eq(chatPublications.attempts, input.payload.publicationAttempt), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const action = publication - ? await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.id, input.actionId), - eq(chatActions.companyId, publication.companyId), - eq(chatActions.endpointId, publication.endpointId), - eq(chatActions.conversationId, publication.conversationId), - eq(chatActions.kind, "slack_file_upload_receipt"), - eq( - chatActions.providerActionId, - `slack-file-receipt:${publication.id}:${publication.attempts}`, - ), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const currentPayload = action - ? slackFileUploadReceiptPayload(action.payload) - : null; - if ( - !publication || - !action || - !publication.payload.attachmentIds?.length || - publication.payload.card || - publication.payload.interactionId || - publication.idempotencyKey.startsWith("control:") || - !currentPayload || - !sameSlackFileUploadReceipt(currentPayload, input.payload) - ) { - return null; - } - if (publication.state === "streaming") { - return { action, mode: "defer" }; - } - if (publication.state === "published" && publication.providerMessageId) { - const exactLink = await tx - .select({ id: chatMessageLinks.id }) - .from(chatMessageLinks) - .where( - and( - eq(chatMessageLinks.companyId, publication.companyId), - eq(chatMessageLinks.endpointId, publication.endpointId), - eq(chatMessageLinks.conversationId, publication.conversationId), - eq(chatMessageLinks.publicationId, publication.id), - publication.commentId - ? eq(chatMessageLinks.commentId, publication.commentId) - : isNull(chatMessageLinks.commentId), - eq( - chatMessageLinks.providerMessageId, - publication.providerMessageId, - ), - eq(chatMessageLinks.direction, "outbound"), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - return exactLink ? { action, mode: "settled" } : null; - } - const mode = - publication.state === "delivery_unknown" - ? "delivery_unknown" - : publication.state === "published" && - !publication.providerMessageId && - operatorConfirmedSlackFileReceipt(action.result, input.payload) - ? "operator_confirmed" - : null; - if (!mode) return null; - const endpoint = await runtimeCallbackEndpoint( - tx, - publication.endpointId, - { - generation: input.payload.runtimeGeneration, - credentialFingerprint: input.payload.credentialFingerprint, - }, - ["verifying", "active"], - ); - const conversation = endpoint - ? await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.id, publication.conversationId), - eq(chatConversations.companyId, publication.companyId), - eq(chatConversations.endpointId, publication.endpointId), - eq(chatConversations.externalThreadId, input.payload.threadId), - inArray(chatConversations.state, [ - "active", - "waiting", - "completed", - ]), - ), - ) - .for("no key update") - .then((rows) => rows[0] ?? null) - : null; - const resource = - endpoint && conversation?.resourceId && !conversation.isDirectMessage - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.id, conversation.resourceId), - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - ), - ) - .for("no key update") - .then((rows) => rows[0] ?? null) - : null; - if ( - endpoint?.provider !== "slack" || - endpoint.botExternalId !== input.payload.botExternalId || - !conversation || - !(conversation.isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource)) - ) { - return null; - } - return { action, conversation, endpoint, mode, publication }; - } - - async function processPendingSlackFileUploadReceipts(limit = 25) { - const selectedAt = new Date(); - const actions = await db - .select() - .from(chatActions) - .where( - slackReceiptEligibility(selectedAt), - ) - .orderBy(asc(chatActions.updatedAt), asc(chatActions.id)) - .limit(limit); - let processed = 0; - for (const selected of actions) { - const payload = slackFileUploadReceiptPayload(selected.payload); - if (!payload) { - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { - code: "slack_file_upload_receipt_payload_invalid", - retryable: false, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.kind, selected.kind), - eq(chatActions.status, selected.status), - sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, - selected.result === null - ? isNull(chatActions.result) - : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, - ), - ); - }); - continue; - } - const record = await endpointRecord(selected.endpointId); - if (!record) { - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_file_upload_receipt_endpoint_removed" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.kind, selected.kind), - eq(chatActions.status, selected.status), - sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, - selected.result === null - ? isNull(chatActions.result) - : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, - ), - ); - }); - continue; - } - if (["paused", "attention"].includes(record.endpoint.status)) continue; - const ownerToken = randomUUID(); - let attempt = 1; - try { - await withCredentialMutationLease(record.endpoint, async (lease) => { - const claim = await db.transaction(async (tx) => { - await lease.assertOwned(tx); - const locked = await lockSlackFileReceiptAuthorization(tx, { - actionId: selected.id, - payload, - }); - const decisionAt = new Date(); - const priorAttempts = - typeof locked?.action.result?.attempts === "number" && - Number.isSafeInteger(locked.action.result.attempts) - ? locked.action.result.attempts - : 0; - attempt = priorAttempts + 1; - const retryAt = - typeof locked?.action.result?.retryAt === "string" - ? Date.parse(locked.action.result.retryAt) - : Number.NaN; - const eligible = locked - ? locked.action.status === "received" || - (locked.action.status === "failed" && - locked.action.result?.retryable === true && - Number.isFinite(retryAt) && - retryAt <= decisionAt.getTime()) || - (locked.action.status === "processing" && - locked.action.updatedAt.getTime() <= - decisionAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS) - : false; - if (locked && !eligible) return null; - if (locked?.mode === "defer") { - if (locked.action.status === "processing") { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "received", - result: { - code: "slack_file_upload_identity_pending", - attempts: priorAttempts, - }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, locked.action.id)); - } - return null; - } - if (locked?.mode === "settled") { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "processed", - result: { - code: "slack_file_upload_identity_confirmed", - attempts: priorAttempts, - }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, locked.action.id)); - return null; - } - if ( - !locked || - decisionAt.getTime() - - (locked?.action.createdAt ?? selected.createdAt).getTime() > - SLACK_FILE_RECEIPT_MAX_AGE_MS || - attempt > SLACK_FILE_RECEIPT_MAX_ATTEMPTS - ) { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - code: locked - ? "slack_file_upload_receipt_expired" - : "slack_file_upload_receipt_authorization_changed", - attempts: priorAttempts, - }, - updatedAt: decisionAt, - }) - .where( - and( - eq(chatActions.id, selected.id), - inArray(chatActions.status, [ - "received", - "failed", - "processing", - ]), - ), - ); - return null; - } - if ( - !["received", "failed", "processing"].includes( - locked.action.status, - ) - ) { - return null; - } - await notifyChatPublicationWork(tx); - const [claimed] = await tx - .update(chatActions) - .set({ - status: "processing", - result: { - attempts: attempt, - ownerToken, - ...(locked.mode === "operator_confirmed" - ? { - operatorConfirmedPublicationId: - locked.action.result?.operatorConfirmedPublicationId, - operatorConfirmedAttempt: - locked.action.result?.operatorConfirmedAttempt, - operatorConfirmedAt: - locked.action.result?.operatorConfirmedAt, - } - : {}), - }, - updatedAt: decisionAt, - }) - .where( - and( - eq(chatActions.id, locked.action.id), - eq(chatActions.status, locked.action.status), - ), - ) - .returning({ id: chatActions.id }); - await lease.assertOwned(tx); - return claimed ? locked : null; - }); - if (!claim || !("endpoint" in claim)) return; - processed += 1; - const endpointRuntime = await runtimeFor(claim.endpoint); - await lease.assertOwned(); - const providerMessageId = - await endpointRuntime.resolveSlackFileUploadReceipt( - payload.threadId, - payload.fileIds, - ); - if (!providerMessageId) { - await db.transaction(async (tx) => { - await lease.assertOwned(tx); - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "failed", - result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( - { - attempts: attempt, - code: "slack_file_upload_identity_pending", - retryable: true, - retryAt: new Date( - Date.now() + Math.min(60_000, 1_000 * 2 ** attempt), - ).toISOString(), - }, - )}::jsonb`, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - }); - return; - } - await db.transaction(async (tx) => { - await lease.assertOwned(tx); - const locked = await lockSlackFileReceiptAuthorization(tx, { - actionId: selected.id, - payload, - }); - if ( - !locked || - locked.mode === "defer" || - locked.mode === "settled" || - !("publication" in locked) || - locked.action.status !== "processing" || - locked.action.result?.ownerToken !== ownerToken - ) { - if (!locked) { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - attempts: attempt, - code: "slack_file_upload_receipt_authorization_changed", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - } else if (locked.mode === "defer") { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "received", - result: { - attempts: attempt, - code: "slack_file_upload_identity_pending", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - } else { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: - locked.mode === "settled" ? "processed" : "cancelled", - result: { - attempts: attempt, - code: - locked.mode === "settled" - ? "slack_file_upload_identity_confirmed" - : "slack_file_upload_receipt_superseded", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - } - return; - } - const committedAt = new Date(); - if ( - !(await recordExactOutboundPublicationLink(tx, { - publication: locked.publication, - providerMessageId, - })) - ) { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "failed", - result: { - attempts: attempt, - code: "slack_file_upload_receipt_message_conflict", - retryable: false, - }, - updatedAt: committedAt, - }) - .where(eq(chatActions.id, selected.id)); - return; - } - if (locked.mode === "delivery_unknown") { - await notifyChatPublicationWork(tx); - const [settled] = await tx - .update(chatPublications) - .set({ - state: "published", - providerMessageId, - publishedAt: committedAt, - nextAttemptAt: null, - redactedError: null, - updatedAt: committedAt, - }) - .where( - and( - eq(chatPublications.id, locked.publication.id), - eq(chatPublications.state, "delivery_unknown"), - eq(chatPublications.attempts, payload.publicationAttempt), - ), - ) - .returning({ id: chatPublications.id }); - if (!settled) { - throw new Error( - "Slack file upload publication changed before receipt commit", - ); - } - await stageSlackFilePublicationSuccess(tx, { - committedAt, - endpoint: locked.endpoint, - publication: locked.publication, - providerMessageId, - runtimeContext: { - generation: payload.runtimeGeneration, - credentialFingerprint: payload.credentialFingerprint, - }, - stageSessionSync: - !isExplicitOperatorPublication(locked.publication) && - !locked.publication.idempotencyKey.startsWith( - "control:status:", - ), - }); - } else { - await notifyChatPublicationWork(tx); - const [enriched] = await tx - .update(chatPublications) - .set({ providerMessageId }) - .where( - and( - eq(chatPublications.id, locked.publication.id), - eq(chatPublications.state, "published"), - eq(chatPublications.attempts, payload.publicationAttempt), - isNull(chatPublications.providerMessageId), - ), - ) - .returning({ id: chatPublications.id }); - if (!enriched) { - throw new Error( - "Slack file upload publication changed before identity enrichment", - ); - } - } - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "processed", - result: { - attempts: attempt, - code: "slack_file_upload_identity_confirmed", - }, - updatedAt: committedAt, - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - await lease.assertOwned(tx); - }); - }); - } catch (error) { - const disposition = classifyChatPublicationError(error, attempt); - const retryable = - disposition.kind === "retry" || - disposition.kind === "delivery_unknown"; - const retryMs = - disposition.kind === "retry" - ? disposition.retryAfterMs - : Math.min(60_000, 1_000 * 2 ** Math.min(attempt, 8)); - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatActions) - .set({ - status: retryable ? "failed" : "cancelled", - result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( - { - attempts: attempt, - code: retryable - ? "slack_file_upload_receipt_lookup_retry" - : "slack_file_upload_receipt_lookup_rejected", - retryable, - ...(retryable - ? { retryAt: new Date(Date.now() + retryMs).toISOString() } - : {}), - }, - )}::jsonb`, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - }); - } - } - return processed; - } - - async function processPendingSlackSessionSyncs( - limit = 25, - onlyActionId?: string, - ) { - const now = new Date(); - const actions = await db - .select({ action: chatActions }) - .from(chatActions) - .leftJoin(chatEndpoints, and(eq(chatEndpoints.id, chatActions.endpointId), eq(chatEndpoints.companyId, chatActions.companyId))) - .where(and( - onlyActionId ? eq(chatActions.id, onlyActionId) : undefined, - dueChatAction("slack_session_sync", now.getTime()), - )) - .orderBy(asc(chatActions.updatedAt)) - .limit(limit) - .then(rows => rows.map(row => row.action)); - await options.slackSessionSyncSelectionBarrier?.(); - for (const action of actions) { - const payload = slackSessionSyncPayload(action.payload); - if (!payload || !action.conversationId) { - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "slack_session_sync"); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { code: "slack_session_payload_invalid" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.updatedAt, action.updatedAt), - ), - ); - }); - continue; - } - const revisionWhere = and( - eq(chatActions.id, action.id), - sql`(${chatActions.payload}->>'revision')::bigint = ${payload.revision}`, - ); - const selectedSnapshotWhere = and( - revisionWhere, - eq(chatActions.status, action.status), - eq(chatActions.updatedAt, action.updatedAt), - ); - const record = await endpointRecord(action.endpointId); - if (record && ["paused", "attention"].includes(record.endpoint.status)) continue; - if (!record || record.endpoint.provider !== "slack") { - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "slack_session_sync"); - return tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_session_endpoint_unavailable" }, - updatedAt: new Date(), - }) - .where(selectedSnapshotWhere); - }); - continue; - } - const attempts = - (typeof action.result?.attempts === "number" - ? action.result.attempts - : 0) + 1; - const ownerToken = randomUUID(); - let ownsAttempt = false; - try { - await withCredentialMutationLease(record.endpoint, async (lease) => { - const claim = await db.transaction(async (tx) => { - await lease.assertOwned(tx); - const endpoint = await runtimeCallbackEndpoint( - tx, - action.endpointId, - { - generation: payload.runtimeGeneration, - credentialFingerprint: payload.credentialFingerprint, - }, - ["active", "verifying"], - ); - const conversation = endpoint - ? await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.id, action.conversationId!), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.companyId, endpoint.companyId), - inArray(chatConversations.state, [ - "active", - "waiting", - "completed", - ]), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const resource = - endpoint && conversation?.resourceId - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.id, conversation.resourceId), - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const superseded = conversation - ? await tx - .select({ id: chatConversations.id }) - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, conversation.companyId), - eq(chatConversations.endpointId, conversation.endpointId), - eq( - chatConversations.externalThreadId, - conversation.externalThreadId, - ), - gt( - chatConversations.sessionGeneration, - conversation.sessionGeneration, - ), - ), - ) - .limit(1) - .then((rows) => rows.length > 0) - : false; - if ( - !endpoint || - !conversation || - superseded || - !(conversation.isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource)) - ) { - await notifyChatActionWork(tx, "slack_session_sync"); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_session_destination_changed" }, - updatedAt: new Date(), - }) - .where(selectedSnapshotWhere); - return null; - } - // Recompute at dispatch: a delayed processing retry must not - // overwrite a newer final response or a later question card. - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq(chatPublications.conversationId, conversation.id), - eq(chatPublications.state, "published"), - sql`${chatPublications.idempotencyKey} not like 'explicit%'`, - sql`${chatPublications.idempotencyKey} not like 'control:status:%'`, - sql`${chatPublications.idempotencyKey} not like 'wake:%'`, - ), - ) - .orderBy( - desc(chatPublications.publishedAt), - desc(chatPublications.createdAt), - desc(chatPublications.id), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - if (!publication) { - await notifyChatActionWork(tx, "slack_session_sync"); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_session_no_published_state" }, - updatedAt: new Date(), - }) - .where(selectedSnapshotWhere); - return null; - } - let status = slackSessionStatusForPublication( - publication.payload, - conversation.state === "completed", - ); - const runId = runIdFromMilestonePublication(publication); - if (status === "processing" && runId) { - const run = await tx - .select({ status: heartbeatRuns.status }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.id, runId), - eq(heartbeatRuns.companyId, endpoint.companyId), - eq(heartbeatRuns.agentId, endpoint.assignedAgentId), - ), - ) - .then((rows) => rows[0] ?? null); - // Stop may finish before its confirmation publication. Never - // resurrect processing from that run's earlier working receipt. - if (!run || !["queued", "running"].includes(run.status)) - status = "active"; - } - await notifyChatActionWork(tx, "slack_session_sync"); - const claimed = await tx - .update(chatActions) - .set({ - status: "processing", - result: { attempts, ownerToken }, - updatedAt: new Date(), - }) - .where( - and( - selectedSnapshotWhere, - or( - and( - eq(chatActions.status, "received"), - sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${new Date().toISOString()}::timestamptz)`, - ), - and( - eq(chatActions.status, "processing"), - lte(chatActions.updatedAt, new Date(Date.now() - 60_000)), - ), - ), - ), - ) - .returning({ id: chatActions.id }); - await lease.assertOwned(tx); - return claimed.length ? { endpoint, conversation, status } : null; - }); - if (!claim) return; - ownsAttempt = true; - const credentials = await resolveCredentials(claim.endpoint); - await lease.assertOwned(); - const outcome = await setSlackSessionStatus({ - botToken: credentials.botToken, - threadId: claim.conversation.externalThreadId, - status: claim.status, - fetch: fetchImpl, - }); - await db.transaction(async (tx) => { - await lease.assertOwned(tx); - // Refresh long work before Slack's one-hour processing timeout. - // Unsupported sessions settle until a new publication restages - // them; completed threads must not create perpetual polling work. - const retryMs = - outcome === "updated" && claim.status === "processing" - ? 30 * 60_000 - : null; - await notifyChatActionWork(tx, "slack_session_sync"); - await tx - .update(chatActions) - .set({ - status: retryMs === null ? "processed" : "received", - result: { - attempts, - outcome, - sessionStatus: claim.status, - ...(retryMs === null - ? {} - : { - retryAt: new Date(Date.now() + retryMs).toISOString(), - }), - }, - updatedAt: new Date(), - }) - .where( - and( - revisionWhere, - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - }); - }); - } catch (error) { - const disposition = classifyChatPublicationError(error, attempts); - // Unlike posting a message, repeating setStatus cannot duplicate any - // content. Ambiguous transport/receipt failures remain retryable, but - // a definite scope/auth/destination rejection waits for new activity. - const retryable = - disposition.kind === "retry" || - disposition.kind === "delivery_unknown"; - const retryMs = - disposition.kind === "retry" - ? disposition.retryAfterMs - : Math.min(5 * 60_000, 1_000 * 2 ** Math.min(attempts, 8)); - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "slack_session_sync"); - return tx - .update(chatActions) - .set({ - status: retryable ? "received" : "failed", - result: { - attempts, - code: retryable - ? "slack_session_sync_retry" - : "slack_session_sync_rejected", - ...(retryable - ? { retryAt: new Date(Date.now() + retryMs).toISOString() } - : {}), - }, - updatedAt: new Date(), - }) - .where( - and( - revisionWhere, - ownsAttempt - ? and( - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ) - : and( - eq(chatActions.status, "received"), - eq(chatActions.updatedAt, action.updatedAt), - ), - ), - ); - }); - logger.warn( - { - endpointId: action.endpointId, - actionId: action.id, - retryable, - error: redactError(error), - }, - "Slack session status update did not complete; message delivery is unchanged", - ); - } - } - return actions.length; - } - async function withPublicationAttemptLease( publication: typeof chatPublications.$inferSelect, operation: (lease: CredentialMutationLeaseGuard) => Promise, @@ -28958,50 +25574,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); } - function slackReceiptEligibility(selectedAt?: Date) { - return and( - eq(chatActions.kind, "slack_file_upload_receipt"), - notExists( - db - .select({ id: chatEndpoints.id }) - .from(chatEndpoints) - .where( - and( - eq(chatEndpoints.id, chatActions.endpointId), - inArray(chatEndpoints.status, ["paused", "attention"]), - ), - ), - ), - // The normal publication owner is still polling Slack. Receipt - // recovery must neither contend with it nor let its not-yet-due - // action occupy the bounded recovery page. - notExists( - db - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - sql`${chatPublications.id}::text = ${chatActions.payload}->>'publicationId'`, - sql`${chatPublications.attempts}::text = ${chatActions.payload}->>'publicationAttempt'`, - eq(chatPublications.state, "streaming"), - ), - ), - ), - or( - eq(chatActions.status, "received"), - and( - eq(chatActions.status, "failed"), - sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'true'`, - selectedAt ? sql`(${chatActions.result}->>'retryAt')::timestamptz <= ${selectedAt.toISOString()}::timestamptz` : sql`${chatActions.result}->>'retryAt' is not null`, - ), - and( - eq(chatActions.status, "processing"), - selectedAt ? lte(chatActions.updatedAt, new Date(selectedAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS)) : undefined, - ), - ), - ); - } - function publicationEligibility({ now, busyEndpointIds = [], coolingTeamsPublicationIds = [], attemptedIds = [] }: { now?: Date; busyEndpointIds?: string[]; coolingTeamsPublicationIds?: string[]; attemptedIds?: string[] } = {}) { const earlierPublication = alias(chatPublications, "earlier_chat_publications"); return and( @@ -29425,14 +25997,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return next; } - async function nextSlackReceiptAt(): Promise { - const [row] = await db.select({ at: sql`min(case - when ${chatActions.status} = 'processing' then ${chatActions.updatedAt} + ${SLACK_FILE_RECEIPT_STALE_MS} * interval '1 millisecond' - when ${chatActions.status} = 'failed' then (${chatActions.result}->>'retryAt')::timestamptz - else now() end)` }).from(chatActions).where(slackReceiptEligibility()); - return row?.at == null ? null : new Date(row.at).getTime(); - } - async function nextPublicationAt(): Promise { const cooldowns = Object.fromEntries([...teamsFileRetryAfter].map(([id, at]) => [id, new Date(at).toISOString()])); const [queued, streaming] = await Promise.all([ diff --git a/server/src/services/chat-channels/imessage-photon/events.ts b/server/src/services/chat-channels/imessage-photon/events.ts new file mode 100644 index 00000000000..14a914287fd --- /dev/null +++ b/server/src/services/chat-channels/imessage-photon/events.ts @@ -0,0 +1,903 @@ +import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { nativeSha256 } from "../../native-runtime/canonical.js"; + +import { projectSafeChatPublicationText } from "../../chat-publication-projection.js"; +import { PhotonAnswerValidationError, nativePhotonInteraction, photonResponseCommand, parsePhotonQuestionAnswer, type PhotonPromptReceipt, type PhotonInteractionBinding, type PhotonDraft } from "../../photon/interactions.js"; +import { validateNativeQuestionResponseInput } from "../../native-runtime/native-question-bridge.js"; +import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction } from "@paperclipai/shared"; +import { PhotonError, photonFailure } from "../../photon/cloud.js"; +import { PhotonChatAdapter, photonThreadId } from "../../photon/adapter.js"; + +import type { LiveEvent as PhotonEvent } from "@photon-ai/advanced-imessage"; + +import { + and, + eq, + inArray, + isNotNull, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointResources, + chatEndpoints, + chatExternalPrincipals, + chatPublications, + issues, + toolConnections, +} from "@paperclipai/db"; + +import { + HttpError, + forbidden, +} from "../../../errors.js"; + +import { + type ChatSdkMessageCallbackEvent, +} from "../../chat-sdk-runtime.js"; + +import { + logActivity, +} from "../../activity-log.js"; + +import { issueThreadInteractionService } from "../../issue-thread-interactions.js"; +import { + questionResponseDeliveryService, +} from "../../question-response-delivery.js"; + +import type { + Author, +} from "chat"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type PhotonEventsOptions = { + runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + invalidateRuntime: (endpointId: string) => Promise; + ensurePrincipal: (endpoint: EndpointRow, author: Author, raw?: unknown) => Promise<{ principal: Pick; userId: string | null; linkedDenied: boolean }>; + requireCurrentExternalActionAuthorization: (tx: DbTransaction, input: { conversationId: string; endpointId: string; expectedUserId: string; principalId: string; runtimeContext: LifecycleRuntimeFence }) => Promise; + isExternalActionAuthorizationChange: (error: unknown) => boolean; + scheduleMessageProcessing: (task: () => Promise) => void; + processPendingPublications: () => Promise; + questionResponses: Pick, "deliver">; + redactError: (error: unknown) => string; + renewDiscordGatewayOwnershipForMessageAdmission: (tx: DbTransaction, endpointId: string, context: RuntimeContext) => Promise<{ kind: "owned" | "lost" }>; + handleSdkMessage: (event: ChatSdkMessageCallbackEvent, runtimeContext?: RuntimeContext, messageOptions?: { receiptReactionSupported?: boolean; slackSlashControl?: boolean }) => Promise; +}; + +export function createPhotonEvents(db: Db, options: PhotonEventsOptions) { + const { runtimeCallbackRecord, runtimeCallbackEndpoint, invalidateRuntime, ensurePrincipal, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications, questionResponses, redactError, renewDiscordGatewayOwnershipForMessageAdmission, handleSdkMessage } = options; + + + async function handlePhotonFailure( + endpointId: string, + error: unknown, + context: RuntimeContext, + ) { + if (error instanceof PhotonError && error.code === "attachment_not_ready") + return; + error = photonFailure(error); + const fatal = + error instanceof PhotonError && + [ + "credentials", + "line_unavailable", + "history_gap", + "invalid_response", + ].includes(error.code); + const message = + error instanceof PhotonError + ? error.message + : "Photon connection interrupted; reconnecting"; + await db.transaction(async (tx) => { + const endpoint = await runtimeCallbackEndpoint(tx, endpointId, context, [ + "verifying", + "active", + "attention", + ]); + if (!endpoint) return; + await tx + .update(chatEndpoints) + .set({ + status: "attention", + healthMessage: message, + lastError: message, + updatedAt: new Date(), + }) + .where(eq(chatEndpoints.id, endpointId)); + if (fatal) + await tx + .update(toolConnections) + .set({ + enabled: false, + healthStatus: "error", + healthMessage: message, + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + }); + // Retirement is queued outside the receiver callback to avoid self-joining. + void invalidateRuntime(endpointId).catch(() => undefined); + } + + async function processPhotonResponse( + endpoint: EndpointRow, + event: PhotonEvent, + threadId: string, + adapter: PhotonChatAdapter, + context: RuntimeContext, + ): Promise { + const command = + event.type === "message.received" + ? photonResponseCommand(event.message.content.text ?? "") + : null; + const replyGuid = + event.type === "message.received" ? event.message.replyTargetGuid : null; + const prompt = + event.type === "poll.changed" + ? await adapter.state.read( + `poll-message:${event.pollMessageGuid}`, + ) + : replyGuid + ? ((await adapter.state.read( + `prompt-message:${replyGuid}`, + )) ?? + (await adapter.state.read( + `poll-message:${replyGuid}`, + ))) + : null; + const reference = command?.reference ?? prompt?.reference; + if (!reference) { + if ( + !event.isFromMe && + (replyGuid || + (event.type === "poll.changed" && + (event.delta.type === "voted" || event.delta.type === "unvoted"))) + ) { + // A vote can beat the local poll-binding write. Keep it behind the + // checkpoint while a publication in this chat is in flight. + const pending = await db + .select({ id: chatPublications.id }) + .from(chatPublications) + .innerJoin( + chatConversations, + eq(chatConversations.id, chatPublications.conversationId), + ) + .where( + and( + eq(chatPublications.endpointId, endpoint.id), + eq(chatConversations.externalThreadId, threadId), + inArray(chatPublications.state, [ + "pending", + "retry", + "streaming", + "delivery_unknown", + ]), + isNotNull(sql`${chatPublications.payload}->>'interactionId'`), + ), + ) + .limit(1); + if (pending.length) + throw new PhotonError( + "attachment_not_ready", + "Waiting for Photon poll binding", + ); + } + return ( + event.type === "poll.changed" || + (event.type === "message.received" && + /^\/(answer|submit)\b/i.test(event.message.content.text ?? "")) + ); + } + const action = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, endpoint.companyId), + eq(chatActions.endpointId, endpoint.id), + eq(chatActions.kind, "photon_interaction"), + eq(chatActions.providerActionId, `photon:${reference}`), + ), + ) + .then((rows) => rows[0]); + if (!action || action.status !== "issued" || !action.conversationId) + return true; + const binding = action.payload as unknown as PhotonInteractionBinding; + if ( + binding.version !== 1 || + binding.reference !== reference || + !Number.isFinite(Date.parse(binding.expiresAt)) || + Date.parse(binding.expiresAt) <= Date.now() + ) + return true; + const conversation = await db + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, action.conversationId), + ), + ) + .then((rows) => rows[0]); + if ( + !conversation || + !["active", "waiting"].includes(conversation.state) || + conversation.sessionGeneration !== binding.sessionGeneration || + conversation.externalThreadId !== threadId + ) + return true; + const publication = await db + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq(chatPublications.id, binding.publicationId), + eq(chatPublications.conversationId, conversation.id), + ), + ) + .then((rows) => rows[0]); + if ( + !publication || + publication.payload.interactionId !== binding.interactionId || + new Date(event.occurredAt).getTime() < action.createdAt.getTime() + ) + return true; + if ( + ["pending", "retry", "streaming", "delivery_unknown"].includes( + publication.state, + ) + ) + throw new PhotonError( + "attachment_not_ready", + "Waiting for Photon prompt publication", + ); + if (publication.state !== "published") return true; + const interaction = ( + await issueThreadInteractionService(db).listForIssue(conversation.issueId) + ).find((candidate) => candidate.id === binding.interactionId); + if ( + !interaction || + interaction.status !== "pending" || + interaction.companyId !== endpoint.companyId || + interaction.createdByAgentId !== endpoint.assignedAgentId || + !nativePhotonInteraction(interaction) + ) + return true; + const actor = + event.type === "message.received" ? event.message.sender : event.actor; + if (event.isFromMe || !actor?.address || actor.service !== "iMessage") + return true; + const chat = await adapter.chatInfo(threadId); + if ( + chat.isArchived || + !chat.participants.some( + (person) => + person.address === actor.address && person.service === actor.service, + ) + ) + return true; + const principal = await ensurePrincipal( + endpoint, + { + userId: `${actor.service}:${actor.address}`, + userName: actor.address, + fullName: actor.address, + isMe: false, + isBot: false, + }, + event, + ); + if ( + !principal.userId || + principal.linkedDenied || + principal.principal.kind !== "user" || + principal.principal.isBot + ) + return true; + const assertCurrent = async () => + db.transaction((tx) => + requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext: context, + }), + ); + try { + await assertCurrent(); + } catch (error) { + if (isExternalActionAuthorizationChange(error)) return true; + throw error; + } + const draftKey = `draft:${reference}:${principal.principal.id}:${principal.userId}`; + let draft = (await adapter.state.read(draftKey)) ?? { + schema: 1, + interactionId: interaction.id, + principalId: principal.principal.id, + userId: principal.userId, + answers: [], + lastSequence: 0, + }; + if (draft.lastSequence > event.sequence) return true; + const questionIndex = command?.questionIndex ?? prompt?.questionIndex ?? 0; + let answerValue = + command?.value ?? + (event.type === "message.received" + ? (event.message.content.text ?? "") + : ""); + let pollChoice: string | undefined; + if (event.type === "poll.changed") { + if (!prompt || event.pollMessageGuid !== prompt.pollMessageGuid) + return true; + if (event.delta.type !== "voted" && event.delta.type !== "unvoted") + return true; + pollChoice = prompt.options[event.delta.optionIdentifier]; + if (!pollChoice) return true; + if (event.delta.type === "unvoted") { + const questionId = + interaction.kind === "ask_user_questions" + ? interaction.payload.questions[questionIndex]?.id + : null; + await adapter.state.update(draftKey, () => ({ + ...draft, + answers: draft.answers.filter( + (answer) => + answer.questionId !== questionId || + !answer.optionIds.includes(pollChoice!), + ), + decision: draft.decision === pollChoice ? undefined : draft.decision, + lastSequence: event.sequence, + })); + return true; + } + } + const enqueueResponse = async (key: string, text: string) => { + await assertCurrent(); + await db.transaction(async (tx) => { + await tx + .insert(chatActions) + .values({ + companyId: endpoint.companyId, + endpointId: endpoint.id, + conversationId: conversation.id, + kind: "photon_response_notice", + providerActionId: `photon-notice:${key}`, + payload: { + reference, + questionIndex, + publicationId: binding.publicationId, + }, + status: "processed", + }) + .onConflictDoNothing(); + await notifyChatPublicationWork(tx); + await tx + .insert(chatPublications) + .values({ + companyId: endpoint.companyId, + endpointId: endpoint.id, + conversationId: conversation.id, + issueId: conversation.issueId, + idempotencyKey: key, + payload: { + ...publication.payload, + card: undefined, + text: projectSafeChatPublicationText(text), + }, + state: "pending", + }) + .onConflictDoNothing(); + }); + scheduleMessageProcessing(() => + processPendingPublications().then(() => undefined), + ); + }; + const notice = (text: string) => + enqueueResponse(`photon-response:${reference}:${event.sequence}`, text); + try { + if (interaction.kind === "ask_user_questions") { + if (command?.command !== "submit") { + let answer: AskUserQuestionsAnswer; + if (pollChoice) { + const question = interaction.payload.questions[questionIndex]; + if ( + !question || + !question.options.some((option) => option.id === pollChoice) + ) + return true; + answer = { questionId: question.id, optionIds: [pollChoice] }; + } else + answer = parsePhotonQuestionAnswer( + interaction, + questionIndex, + answerValue, + ); + draft = { + ...draft, + answers: [ + ...draft.answers.filter( + (entry) => entry.questionId !== answer.questionId, + ), + answer, + ], + lastSequence: event.sequence, + }; + } + const answers = interaction.payload.questions.flatMap((question) => + draft.answers.filter((answer) => answer.questionId === question.id), + ); + if ( + interaction.payload.questions.length > 1 && + command?.command !== "submit" + ) { + await adapter.state.update(draftKey, () => draft); + const next = interaction.payload.questions.findIndex( + (question) => + !answers.some((answer) => answer.questionId === question.id), + ); + if (next >= 0) + await enqueueResponse( + `photon-question:${reference}:${next}`, + "Input needed", + ); + else + await notice( + `Your answers are saved. Send /submit ${reference} to submit them.`, + ); + return true; + } + await validateNativeQuestionResponseInput(interaction, { answers }); + // Canonical service rechecks required fields, options, audience, and + // pending -> answered atomically with the response-delivery receipt. + const issue = await db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .then((rows) => rows[0]); + if (!issue) return true; + const answered = await issueThreadInteractionService( + db, + ).answerQuestions( + issue, + interaction.id, + { answers }, + { userId: principal.userId }, + { + beforeResolveInTransaction: async (tx) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext: context, + }); + const current = await tx + .select() + .from(chatConversations) + .where(eq(chatConversations.id, conversation.id)) + .then((rows) => rows[0]); + if ( + current?.sessionGeneration !== binding.sessionGeneration || + Date.parse(binding.expiresAt) <= Date.now() + ) + throw forbidden( + "Photon prompt expired or its task generation changed", + ); + }, + afterResolveInTransaction: async (tx, resolved) => { + const claimed = await tx + .update(chatActions) + .set({ + principalId: principal.principal.id, + status: "processed", + result: { + code: "photon_question_answered", + interactionId: resolved.id, + interactionStatus: resolved.status, + answersSha256: nativeSha256( + (resolved as AskUserQuestionsInteraction).result?.answers, + ), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "issued"), + ), + ) + .returning({ id: chatActions.id }); + if (!claimed.length) + throw forbidden("Photon prompt has already been resolved"); + await logActivity(tx as unknown as Db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: principal.userId!, + action: "issue.thread_interaction_answered", + entityType: "issue", + entityId: issue.id, + details: { + source: "external_chat", + provider: endpoint.provider, + endpointId: endpoint.id, + conversationId: conversation.id, + interactionId: resolved.id, + }, + }); + }, + }, + ); + scheduleMessageProcessing(async () => { + await questionResponses.deliver(answered.id); + await processPendingPublications(); + }); + } else { + if (command?.command === "submit") return true; + const matched = /^(accept|reject|1|2)(?:\s+([\s\S]+))?$/i.exec( + answerValue.trim(), + ); + const decision = + pollChoice === "accept" || pollChoice === "reject" + ? pollChoice + : matched + ? /^(accept|1)$/i.test(matched[1]) + ? "accept" + : "reject" + : draft.decision; + if (!decision) + throw new PhotonAnswerValidationError( + `Send /answer ${reference} Accept or /answer ${reference} Reject .`, + ); + const reason = + matched?.[2]?.trim() ?? + (draft.decision === "reject" && !pollChoice + ? answerValue.trim() + : ""); + if ( + decision === "reject" && + interaction.payload.rejectRequiresReason && + !reason + ) { + await adapter.state.update(draftKey, () => ({ + ...draft, + decision, + lastSequence: event.sequence, + })); + await notice( + `Give a rejection reason with /answer ${reference} Reject .`, + ); + return true; + } + const issue = await db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .then((rows) => rows[0]); + if (!issue) return true; + const mutation = { + beforeResolveInTransaction: async (tx: DbTransaction) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext: context, + }); + const current = await tx + .select() + .from(chatConversations) + .where(eq(chatConversations.id, conversation.id)) + .then((rows) => rows[0]); + if ( + current?.sessionGeneration !== binding.sessionGeneration || + Date.parse(binding.expiresAt) <= Date.now() + ) + throw forbidden( + "Photon prompt expired or its task generation changed", + ); + }, + afterResolveInTransaction: async ( + tx: DbTransaction, + resolved: IssueThreadInteraction, + ) => { + const claimed = await tx + .update(chatActions) + .set({ + principalId: principal.principal.id, + status: "processed", + result: { + code: "photon_confirmation_resolved", + interactionId: resolved.id, + interactionStatus: resolved.status, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "issued"), + ), + ) + .returning({ id: chatActions.id }); + if (!claimed.length) + throw forbidden("Photon prompt has already been resolved"); + await logActivity(tx as unknown as Db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: principal.userId!, + action: + decision === "accept" + ? "issue.thread_interaction_accepted" + : "issue.thread_interaction_rejected", + entityType: "issue", + entityId: issue.id, + details: { + source: "external_chat", + provider: endpoint.provider, + endpointId: endpoint.id, + conversationId: conversation.id, + interactionId: resolved.id, + }, + }); + }, + }; + if (decision === "accept") + await issueThreadInteractionService(db).acceptInteraction( + issue, + interaction.id, + {}, + { userId: principal.userId }, + mutation, + ); + else + await issueThreadInteractionService(db).rejectInteraction( + issue, + interaction.id, + { reason }, + { userId: principal.userId }, + mutation, + ); + scheduleMessageProcessing(async () => { + await processPendingPublications(); + }); + } + } catch (error) { + if (error instanceof PhotonError) throw error; + if (isExternalActionAuthorizationChange(error)) return true; + if (error instanceof HttpError && [403, 404, 409].includes(error.status)) + return true; + // Only validator errors become correction copy. Infrastructure errors + // leave the checkpoint unchanged and retry through durable recovery. + if ( + (error instanceof HttpError && error.status === 422) || + error instanceof PhotonAnswerValidationError + ) { + const missingIndex = interaction.kind === "ask_user_questions" && command?.command === "submit" + ? interaction.payload.questions.findIndex((question) => + question.required !== false && !draft.answers.some((answer) => answer.questionId === question.id)) + : -1; + await notice( + `${redactError(error)} Send /answer ${reference}.${(missingIndex >= 0 ? missingIndex : questionIndex) + 1} to correct it.`, + ); + return true; + } + throw error; + } + return true; + } + + async function handlePhotonEvent( + endpointId: string, + event: PhotonEvent, + context: RuntimeContext, + ) { + const record = await runtimeCallbackRecord(endpointId, context, [ + "verifying", + "active", + "attention", + ]); + if (!record) + throw new Error("Photon receiver no longer owns this endpoint"); + const adapter = context.endpointRuntime!.getProviderAdapter(); + if (!(adapter instanceof PhotonChatAdapter)) + throw new Error("Photon adapter unavailable"); + if (adapter.authentication.identity.allocation === "shared" && event.type === "group.changed") return; + if ( + event.type === "group.changed" && + (event.change.type === "participantRemoved" || + event.change.type === "participantLeft") && + event.change.participant.address === record.endpoint.botExternalId + ) { + const removedThreadId = photonThreadId({ + lineId: adapter.authentication.identity.lineId, + chatGuid: event.chatGuid, + isGroup: true, + }); + await db.transaction(async (tx) => { + if ( + !(await runtimeCallbackEndpoint(tx, endpointId, context, [ + "verifying", + "active", + ])) + ) + throw new Error("Photon receiver is no longer current"); + if ( + ( + await renewDiscordGatewayOwnershipForMessageAdmission( + tx, + endpointId, + context, + ) + ).kind !== "owned" + ) + throw new Error("Photon receiver lease was replaced"); + // Removal can be the first retained event after activation. Preserve a + // tombstone even when the group has never been discovered locally. + await tx + .insert(chatEndpointResources) + .values({ + companyId: record.endpoint.companyId, + endpointId, + providerResourceId: removedThreadId, + type: "group_chat", + label: "Unavailable iMessage group", + enabled: false, + availability: "unavailable", + metadata: { photonRemoved: true }, + }) + .onConflictDoUpdate({ + target: [ + chatEndpointResources.endpointId, + chatEndpointResources.type, + chatEndpointResources.providerResourceId, + ], + set: { + availability: "unavailable", + metadata: sql`jsonb_set(${chatEndpointResources.metadata}, '{photonRemoved}', 'true'::jsonb)`, + updatedAt: new Date(), + }, + }); + }); + await adapter.endTyping(removedThreadId); + return; + } + const groupIdentity = photonThreadId({ lineId: adapter.authentication.identity.lineId, chatGuid: event.chatGuid, isGroup: true }); + const removedGroup = await db.select({ metadata: chatEndpointResources.metadata }).from(chatEndpointResources).where(and(eq(chatEndpointResources.companyId, record.endpoint.companyId), eq(chatEndpointResources.endpointId, endpointId), eq(chatEndpointResources.providerResourceId, groupIdentity))).limit(1).then((rows) => rows[0]); + const readded = event.type === "group.changed" && event.change.type === "participantAdded" && event.change.participant.address === record.endpoint.botExternalId; + // Late events from a removed chat must still advance recovery without a + // now-forbidden chat lookup holding every other conversation behind them. + if (removedGroup?.metadata.photonRemoved === true && !readded) return; + const chat = await adapter.client.chats + .get(event.chatGuid) + .catch((error) => { + throw photonFailure(error); + }); + if (chat.guid !== event.chatGuid || chat.service !== "iMessage" || (chat.isGroup && adapter.authentication.identity.allocation === "shared")) return; + const threadId = photonThreadId({ + lineId: adapter.authentication.identity.lineId, + chatGuid: chat.guid, + isGroup: chat.isGroup, + }); + const removed = + event.type === "group.changed" && + (event.change.type === "participantRemoved" || + event.change.type === "participantLeft") && + event.change.participant.address === record.endpoint.botExternalId; + const available = await db.transaction(async (tx) => { + if ( + !(await runtimeCallbackEndpoint(tx, endpointId, context, [ + "verifying", + "active", + ])) + ) + throw new Error("Photon receiver is no longer current"); + const ownership = await renewDiscordGatewayOwnershipForMessageAdmission( + tx, + endpointId, + context, + ); + if (ownership.kind !== "owned") + throw new Error("Photon receiver lease was replaced"); + const existing = await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, endpointId), + eq(chatEndpointResources.providerResourceId, threadId), + eq( + chatEndpointResources.type, + chat.isGroup ? "group_chat" : "direct_message", + ), + ), + ) + .then((rows) => rows[0]); + const added = + event.type === "group.changed" && + event.change.type === "participantAdded" && + event.change.participant.address === record.endpoint.botExternalId; + const stillRemoved = + removed || (existing?.metadata.photonRemoved === true && !added); + const availability = + stillRemoved || chat.isArchived ? "unavailable" : "available"; + const metadata = { + photonRemoved: stillRemoved, + participants: chat.participants + .slice(0, 256) + .map((p) => ({ address: p.address, service: p.service })), + }; + const label = ( + chat.displayName || chat.participants.map((p) => p.address).join(", ") + ).slice(0, 512); + await tx + .insert(chatEndpointResources) + .values({ + companyId: record.endpoint.companyId, + endpointId, + providerResourceId: threadId, + type: chat.isGroup ? "group_chat" : "direct_message", + label, + enabled: !chat.isGroup, + availability, + metadata, + }) + .onConflictDoUpdate({ + target: [ + chatEndpointResources.endpointId, + chatEndpointResources.type, + chatEndpointResources.providerResourceId, + ], + set: { label, availability, metadata, updatedAt: new Date() }, + }); + return availability === "available"; + }); + if (!available) return; + if ( + !event.isFromMe && + (await processPhotonResponse( + record.endpoint, + event, + threadId, + adapter, + context, + )) + ) + return; + if (event.type === "message.received" && !event.isFromMe) { + await handleSdkMessage( + { + provider: "imessage-photon", + endpointId, + thread: context.endpointRuntime!.thread(threadId), + message: adapter.normalize(event.message, chat), + trigger: chat.isGroup ? "unaddressed_message" : "direct_message", + }, + context, + ); + } + } + return { handlePhotonFailure, handlePhotonEvent }; +} diff --git a/server/src/services/chat-channels/microsoft-teams/file-transfer-authority.ts b/server/src/services/chat-channels/microsoft-teams/file-transfer-authority.ts new file mode 100644 index 00000000000..ff58df90f2f --- /dev/null +++ b/server/src/services/chat-channels/microsoft-teams/file-transfer-authority.ts @@ -0,0 +1,1041 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; +import { createChatFailedRunRetry, type ChatFailedRetrySource as FailedChatRetrySource } from "../../chat-channels/failed-run-retry.js"; + +import { type ChatRuntime } from "../../chat-runtime.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + asc, + eq, + gt, + inArray, + isNotNull, + isNull, + ne, + or, + sql, +} from "drizzle-orm"; + +import { + canonicalTeamsThreadId, +} from "../../chat-control-chronology.js"; + +import type { Db } from "@paperclipai/db"; +import { + createDurableChatWakeupRequest, +} from "../../durable-chat-wakeup.js"; +import { + agentWakeupRequests, + assets, + chatActions, + chatConversations, + chatDeliveries, + chatEndpointResources, + chatEndpoints, + chatExternalPrincipals, + chatIdentityLinks, + chatMessageLinks, + chatPublications, + companyMemberships, + heartbeatRuns, + issueComments, + issueAttachments, + issues, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpointSetupState, + ToolCredentialSecretRef, +} from "@paperclipai/shared"; +import { + isUuidLike, +} from "@paperclipai/shared"; +import { + isAllowedContentType, + MAX_ATTACHMENT_BYTES, +} from "../../../attachment-types.js"; + +import { + bindTeamsPersonalRecipient, + parseTeamsPersonalRecipient, +} from "../../chat-teams-personal-recipient.js"; +import { + type TeamsFileTransferAuthority, +} from "../../chat-teams-file-transfers.js"; + +import { + HttpError, + conflict, +} from "../../../errors.js"; + +import { + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; + +import { + authorizeNativeChatReviewPresentation, + NativeChatReviewPresentationContentionError, +} from "../../native-runtime/native-chat-review-presentation.js"; + +type ConversationRow = typeof chatConversations.$inferSelect; +type TeamsFileAuthorityOptions = { + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + runtime: Pick; + runtimeContexts: Pick, "get">; + localRuntimeEpoch: (endpointId: string) => number; + runtimeVersions: ReadonlyMap; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; userId: string | null }>; + lifecycleRuntimeFence: (delivery: typeof chatDeliveries.$inferSelect) => LifecycleRuntimeFence | null; + assertFailedRetryReceipt: ReturnType["assertFailedRetryReceipt"]; + inboundWakeScope: (action: typeof chatActions.$inferSelect) => string; + failedChatRetrySource: ReturnType["failedChatRetrySource"]; + isExplicitOperatorPublication: (publication: typeof chatPublications.$inferSelect) => boolean; + retryCommentIds: ReturnType["retryCommentIds"]; +}; + +export function createTeamsFileAuthority(options: TeamsFileAuthorityOptions) { + const { runtimeGeneration, credentialFingerprint, runtime, runtimeContexts, localRuntimeEpoch, runtimeVersions, lockCurrentPrincipalAuthorization, lifecycleRuntimeFence, assertFailedRetryReceipt, inboundWakeScope, failedChatRetrySource, isExplicitOperatorPublication, retryCommentIds } = options; + + + // Native Teams file consent is authority for one personal recipient and one + // immutable file, not authority inferred from the SDK's latest route cache. + const teamsBoardFileIntentKind = "teams_board_file_intent"; + const teamsFileProofHash = (value: unknown) => + createHash("sha256") + .update( + JSON.stringify(value, (_key, entry) => + entry && typeof entry === "object" && !Array.isArray(entry) + ? Object.fromEntries( + Object.keys(entry) + .sort() + .map((key) => [key, entry[key]]), + ) + : entry, + ), + ) + .digest("hex"); + + async function teamsFileCurrentScope( + tx: DbTransaction, + supplied: typeof chatPublications.$inferSelect, + fence?: LifecycleRuntimeFence, + ) { + const [endpoint] = await tx + .select() + .from(chatEndpoints) + .where( + and( + eq(chatEndpoints.companyId, supplied.companyId), + eq(chatEndpoints.id, supplied.endpointId), + ), + ) + .for("no key update", { noWait: true }); + if ( + !endpoint || + endpoint.provider !== "microsoft-teams" || + endpoint.status !== "active" || + !endpoint.allowDirectMessages || + !isUuidLike(endpoint.providerAccountId) || + !isUuidLike(endpoint.botExternalId) + ) + return null; + const [connection] = await tx + .select() + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .for("share", { noWait: true }); + if (!connection?.enabled || connection.status !== "active") return null; + const currentFence = { + generation: runtimeGeneration(endpoint.setup), + credentialFingerprint: credentialFingerprint( + connection.credentialSecretRefs, + ), + }; + const instance = runtime.get(endpoint.id); + const registration = instance + ? runtimeContexts.get(instance as object) + : null; + if ( + !instance || + !registration || + registration.generation !== currentFence.generation || + registration.credentialFingerprint !== + currentFence.credentialFingerprint || + registration.localEpoch !== localRuntimeEpoch(endpoint.id) || + runtimeVersions.get(endpoint.id) !== registration.version || + (fence && + (fence.generation !== currentFence.generation || + fence.credentialFingerprint !== currentFence.credentialFingerprint)) + ) + return null; + const [conversation] = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, supplied.conversationId), + eq(chatConversations.issueId, supplied.issueId), + ), + ) + .for("update", { noWait: true }); + if ( + !conversation?.isDirectMessage || + !["active", "waiting"].includes(conversation.state) + ) + return null; + const [superseded] = await tx + .select({ id: chatConversations.id }) + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.externalThreadId, conversation.externalThreadId), + gt( + chatConversations.sessionGeneration, + conversation.sessionGeneration, + ), + ), + ) + .limit(1); + if (superseded) return null; + const [issue] = await tx + .select({ id: issues.id, assigneeAgentId: issues.assigneeAgentId }) + .from(issues) + .where( + and( + eq(issues.companyId, endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .for("share", { noWait: true }); + if (!issue || issue.assigneeAgentId !== endpoint.assignedAgentId) + return null; + // The conversation lock serializes rollover. The endpoint lock serializes + // reach/credential changes; no lock here waits behind inverse Board order. + if (conversation.resourceId) + await tx + .select({ id: chatEndpointResources.id }) + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.id, conversation.resourceId), + ), + ) + .for("update", { noWait: true }); + const [publication] = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq(chatPublications.conversationId, conversation.id), + eq(chatPublications.issueId, conversation.issueId), + eq(chatPublications.id, supplied.id), + ), + ) + .for("update", { noWait: true }); + const ids = publication?.payload.attachmentIds; + if ( + !publication?.commentId || + !Array.isArray(ids) || + ids.length !== 1 || + !isUuidLike(ids[0]) || + publication.payload.progressState !== undefined || + publication.payload.interactionId || + publication.payload.card || + publication.idempotencyKey.startsWith("control:") || + publication.idempotencyKey.startsWith("wake:") + ) + return null; + const [comment] = await tx + .select() + .from(issueComments) + .where( + and( + eq(issueComments.companyId, endpoint.companyId), + eq(issueComments.issueId, conversation.issueId), + eq(issueComments.id, publication.commentId), + isNull(issueComments.deletedAt), + ), + ) + .for("share", { noWait: true }); + const [file] = await tx + .select({ attachment: issueAttachments, asset: assets }) + .from(issueAttachments) + .innerJoin( + assets, + and( + eq(assets.id, issueAttachments.assetId), + eq(assets.companyId, endpoint.companyId), + ), + ) + .where( + and( + eq(issueAttachments.companyId, endpoint.companyId), + eq(issueAttachments.issueId, conversation.issueId), + eq(issueAttachments.issueCommentId, publication.commentId), + eq(issueAttachments.id, ids[0]), + ), + ) + .for("share", { noWait: true }); + const filename = + file?.asset.originalFilename ?? + (file ? `attachment-${file.attachment.id}` : ""); + if ( + !comment || + comment.updatedAt.getTime() !== comment.createdAt.getTime() || + !file || + !Number.isSafeInteger(file.asset.byteSize) || + file.asset.byteSize <= 0 || + file.asset.byteSize > MAX_ATTACHMENT_BYTES || + file.asset.byteSize >= 60 * 1024 * 1024 || + !/^[a-f0-9]{64}$/.test(file.asset.sha256) || + !file.asset.objectKey.trim() || + !isAllowedContentType(file.asset.contentType) || + !filename || + filename.length > 255 || + filename !== filename.trim() || + filename.endsWith(".") || + /[<>:"/\\|?*\x00-\x1f\x7f]/.test(filename) + ) + return null; + const byteIdentity = { + attachmentId: file.attachment.id, + assetId: file.asset.id, + sha256: file.asset.sha256, + byteSize: file.asset.byteSize, + filename, + contentType: file.asset.contentType, + originatingRunId: file.attachment.originatingRunId, + }; + return { + endpoint, + conversation, + publication, + comment, + file, + filename, + currentFence, + byteIdentity, + instance, + }; + } + + async function teamsFilePrincipalAuthorization( + tx: DbTransaction, + endpoint: EndpointRow, + principalId: string, + ) { + const identity = await tx.execute( + sql`select pg_try_advisory_xact_lock(hashtextextended(${`chat-identity:${endpoint.companyId}:${principalId}`}, 0)) as locked`, + ); + if (!identity[0]?.locked) + throw new NativeChatReviewPresentationContentionError(); + const [link] = await tx + .select() + .from(chatIdentityLinks) + .where( + and( + eq(chatIdentityLinks.companyId, endpoint.companyId), + eq(chatIdentityLinks.endpointId, endpoint.id), + eq(chatIdentityLinks.principalId, principalId), + ), + ) + .for("update", { noWait: true }); + const userId = + link?.status === "linked" ? link.paperclipUserId : endpoint.sponsorUserId; + if (userId) + await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, endpoint.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + ), + ) + .for("update", { noWait: true }); + // All rows and the identity advisory key used by the common authorization + // helper are already held; the helper cannot introduce an inverse wait. + return lockCurrentPrincipalAuthorization(tx, endpoint, principalId); + } + + async function teamsFileSourceBinding( + tx: DbTransaction, + scope: NonNullable>>, + actionId: string, + ) { + const { endpoint, conversation } = scope; + const [action] = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, endpoint.companyId), + eq(chatActions.endpointId, endpoint.id), + eq(chatActions.conversationId, conversation.id), + eq(chatActions.id, actionId), + eq(chatActions.kind, "inbound_wakeup"), + eq(chatActions.status, "processed"), + ), + ) + .for("update", { noWait: true }); + if ( + !action?.deliveryId || + !action.principalId || + action.payload.version !== 1 || + action.payload.issueId !== conversation.issueId || + action.payload.agentId !== endpoint.assignedAgentId || + action.payload.sessionGeneration !== conversation.sessionGeneration || + typeof action.payload.commentId !== "string" || + !isUuidLike(action.payload.commentId) + ) + return null; + const [delivery] = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, endpoint.companyId), + eq(chatDeliveries.endpointId, endpoint.id), + eq(chatDeliveries.conversationId, conversation.id), + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.principalId, action.principalId), + eq(chatDeliveries.state, "processed"), + inArray(chatDeliveries.eventKind, [ + "direct_message", + "mention", + "message", + ]), + ), + ) + .for("update", { noWait: true }); + const originalFence = delivery ? lifecycleRuntimeFence(delivery) : null; + const [principal] = await tx + .select() + .from(chatExternalPrincipals) + .where( + and( + eq(chatExternalPrincipals.companyId, endpoint.companyId), + eq(chatExternalPrincipals.id, action.principalId), + eq(chatExternalPrincipals.provider, "microsoft-teams"), + eq( + chatExternalPrincipals.providerAccountId, + endpoint.providerAccountId!, + ), + eq(chatExternalPrincipals.kind, "user"), + eq(chatExternalPrincipals.isBot, false), + ), + ) + .for("share", { noWait: true }); + if (!delivery || !originalFence || !principal) return null; + const admission = { + companyId: endpoint.companyId, + endpointId: endpoint.id, + runtimeGeneration: originalFence.generation, + credentialFingerprint: originalFence.credentialFingerprint, + tenantId: endpoint.providerAccountId!, + botAppId: endpoint.botExternalId!, + providerEventId: delivery.providerEventId, + threadId: conversation.externalThreadId, + isDirectMessage: true, + }; + const parsed = parseTeamsPersonalRecipient( + delivery.normalizedEvent.teamsPersonalRecipient, + admission, + ); + const binding = parsed + ? bindTeamsPersonalRecipient(parsed, { + admission, + deliveryId: delivery.id, + principalId: principal.id, + conversationId: conversation.id, + conversationGeneration: conversation.sessionGeneration, + externalPrincipalId: principal.externalId, + }) + : null; + const originalConversation = delivery.normalizedEvent.conversation as + | Record + | undefined; + const originalMessage = delivery.normalizedEvent.message as + | Record + | undefined; + const originalPrincipal = delivery.normalizedEvent.principal as + | Record + | undefined; + if ( + !binding || + originalConversation?.isDirectMessage !== true || + typeof originalConversation.externalThreadId !== "string" || + canonicalTeamsThreadId(originalConversation.externalThreadId) !== + canonicalTeamsThreadId(conversation.externalThreadId) || + originalMessage?.providerMessageId !== + binding.recipient.providerActivityId || + originalPrincipal?.externalId !== principal.externalId + ) + return null; + const [source] = await tx + .select({ comment: issueComments, link: chatMessageLinks }) + .from(chatMessageLinks) + .innerJoin( + issueComments, + and( + eq(issueComments.id, chatMessageLinks.commentId), + eq(issueComments.companyId, endpoint.companyId), + eq(issueComments.issueId, conversation.issueId), + ), + ) + .where( + and( + eq(chatMessageLinks.companyId, endpoint.companyId), + eq(chatMessageLinks.endpointId, endpoint.id), + eq(chatMessageLinks.conversationId, conversation.id), + eq(chatMessageLinks.deliveryId, delivery.id), + eq(chatMessageLinks.commentId, action.payload.commentId), + eq(chatMessageLinks.direction, "inbound"), + ), + ) + .for("share", { noWait: true }); + const expectedUserId = + action.payload.requestedByActorType === "user" + ? action.payload.requestedByActorId + : null; + if ( + !source || + source.link.providerMessageId !== binding.recipient.providerActivityId || + source.comment.deletedAt || + source.comment.createdAt > scope.comment.createdAt || + source.comment.updatedAt.getTime() !== + source.comment.createdAt.getTime() || + source.comment.authorUserId !== expectedUserId || + !["user", "system"].includes( + String(action.payload.requestedByActorType), + ) || + (expectedUserId === null && + action.payload.requestedByActorId !== principal.id) + ) + return null; + const [lifecycle] = await tx + .select({ id: chatDeliveries.id }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, endpoint.companyId), + eq(chatDeliveries.endpointId, endpoint.id), + or( + isNull(chatDeliveries.conversationId), + eq(chatDeliveries.conversationId, conversation.id), + ), + ne(chatDeliveries.state, "filtered"), + inArray(chatDeliveries.eventKind, [ + "message_updated", + "message_deleted", + "message_restored", + ]), + sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(originalFence)}::jsonb`, + sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${delivery.providerEventId}`, + ), + ) + .limit(1); + if (lifecycle) return null; + const authorization = await teamsFilePrincipalAuthorization( + tx, + endpoint, + principal.id, + ); + if (!authorization.allowed || authorization.userId !== expectedUserId) + return null; + const [receipt] = await tx + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, endpoint.companyId), + eq(agentWakeupRequests.id, action.id), + eq(agentWakeupRequests.agentId, endpoint.assignedAgentId), + ), + ) + .for("share", { noWait: true }); + if (!receipt || ["skipped", "cancelled"].includes(receipt.status)) + return null; + try { + assertFailedRetryReceipt( + createDurableChatWakeupRequest({ + id: action.id, + companyId: endpoint.companyId, + agentId: endpoint.assignedAgentId, + issueId: conversation.issueId, + commentId: source.comment.id, + requestedByActorType: action.payload.requestedByActorType as + | "user" + | "system", + requestedByActorId: String(action.payload.requestedByActorId), + requestedAt: action.createdAt, + authorize: async () => {}, + }), + receipt, + ); + } catch (error) { + if ( + error instanceof HttpError && + (error.details as { code?: string } | undefined)?.code === + "chat_failed_run_retry_not_authorized" + ) + return null; + throw error; + } + return { + binding, + authorization, + action, + sourceFacts: { + actionId: action.id, + deliveryId: delivery.id, + commentId: source.comment.id, + principalId: principal.id, + scope: inboundWakeScope(action), + originalFence, + bodySha256: createHash("sha256") + .update(source.comment.body) + .digest("hex"), + receiptId: receipt.id, + recipientBindingSha256: teamsFileProofHash(binding), + }, + }; + } + + async function teamsBoardOriginBinding( + tx: DbTransaction, + scope: NonNullable>>, + ) { + // Pick the task's first admitted source, never the latest DM actor. If the + // durable task contains different recipient principals, consent is ambiguous. + const principals = await tx + .selectDistinct({ id: chatActions.principalId }) + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, "inbound_wakeup"), + eq(chatActions.status, "processed"), + sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, + ), + ) + .limit(2); + if (principals.length !== 1 || !principals[0]?.id) return null; + const [origin] = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, "inbound_wakeup"), + eq(chatActions.status, "processed"), + sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, + ), + ) + .orderBy(asc(chatActions.createdAt), asc(chatActions.id)) + .limit(1); + return origin ? teamsFileSourceBinding(tx, scope, origin.id) : null; + } + + async function teamsBoardAuthorIsCurrent( + tx: DbTransaction, + companyId: string, + userId: string, + ) { + const [membership] = await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + ), + ) + .for("share", { noWait: true }); + return ( + membership?.status === "active" && membership.membershipRole !== "viewer" + ); + } + + function teamsBoardFileIntentPayload( + scope: NonNullable>>, + source: NonNullable>>, + userId: string, + ) { + return { + schema: "paperclip.teams.board-file-intent.v1", + publicationId: scope.publication.id, + issueId: scope.conversation.issueId, + commentId: scope.comment.id, + attachmentId: scope.file.attachment.id, + userId, + conversationGeneration: scope.conversation.sessionGeneration, + sourceActionId: source.action.id, + sourceScopeSha256: teamsFileProofHash(source.sourceFacts), + recipientBindingSha256: teamsFileProofHash(source.binding), + byteIdentitySha256: teamsFileProofHash(scope.byteIdentity), + commentBodySha256: createHash("sha256") + .update(scope.comment.body) + .digest("hex"), + }; + } + + function teamsFileRuntimeStillCurrent( + scope: NonNullable>>, + ) { + const registration = runtimeContexts.get(scope.instance as object); + return ( + runtime.get(scope.endpoint.id) === scope.instance && + registration?.generation === scope.currentFence.generation && + registration.credentialFingerprint === + scope.currentFence.credentialFingerprint && + registration.localEpoch === localRuntimeEpoch(scope.endpoint.id) && + runtimeVersions.get(scope.endpoint.id) === registration.version + ); + } + + async function stageTeamsBoardFileIntent( + tx: DbTransaction, + publication: typeof chatPublications.$inferSelect, + userId: string, + conversation: ConversationRow, + ): Promise { + if ( + !publication.idempotencyKey.startsWith("explicit-board:") || + !conversation.isDirectMessage + ) + return; + const scope = await teamsFileCurrentScope(tx, publication); + if ( + !scope || + scope.conversation.id !== conversation.id || + scope.conversation.sessionGeneration !== conversation.sessionGeneration || + scope.comment.authorUserId !== userId || + scope.comment.authorAgentId || + scope.comment.createdByRunId || + !(await teamsBoardAuthorIsCurrent(tx, scope.endpoint.companyId, userId)) + ) + return; + const source = await teamsBoardOriginBinding(tx, scope); + if (!source || !teamsFileRuntimeStillCurrent(scope)) return; + const key = `${teamsBoardFileIntentKind}:${publication.id}`; + const payload = teamsBoardFileIntentPayload(scope, source, userId); + const [existing] = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.providerActionId, key), + ), + ) + .for("update", { noWait: true }); + if (existing) { + if ( + existing.kind !== teamsBoardFileIntentKind || + existing.status !== "processed" || + existing.deliveryId !== source.binding.deliveryId || + existing.principalId !== source.binding.principalId || + existing.conversationId !== scope.conversation.id || + teamsFileProofHash(existing.payload) !== teamsFileProofHash(payload) + ) + throw conflict("The Teams file recipient intent has changed", { + code: "chat_file_transfer_authority_changed", + }); + return; + } + await tx.insert(chatActions).values({ + companyId: scope.endpoint.companyId, + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + deliveryId: source.binding.deliveryId, + principalId: source.binding.principalId, + kind: teamsBoardFileIntentKind, + providerActionId: key, + status: "processed", + payload, + }); + } + + async function deriveTeamsFileTransferAuthority( + tx: DbTransaction, + publication: typeof chatPublications.$inferSelect, + fence: LifecycleRuntimeFence, + continuingTeamsFileTransfer?: NonNullable< + NonNullable< + Parameters[1]["committedResponse"] + >["continuingTeamsFileTransfer"] + >, + ): Promise { + const scope = await teamsFileCurrentScope(tx, publication, fence); + if (!scope) return null; + let source: NonNullable>>; + let causal: Record; + if (isExplicitOperatorPublication(scope.publication)) { + if ( + !scope.publication.idempotencyKey.startsWith("explicit-board:") || + !scope.comment.authorUserId || + scope.comment.authorAgentId || + scope.comment.createdByRunId || + !(await teamsBoardAuthorIsCurrent( + tx, + scope.endpoint.companyId, + scope.comment.authorUserId, + )) + ) + return null; + const [intent] = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, teamsBoardFileIntentKind), + eq( + chatActions.providerActionId, + `${teamsBoardFileIntentKind}:${scope.publication.id}`, + ), + eq(chatActions.status, "processed"), + ), + ) + .for("share", { noWait: true }); + if ( + !intent || + typeof intent.payload.sourceActionId !== "string" || + !isUuidLike(intent.payload.sourceActionId) + ) + return null; + const bound = await teamsBoardOriginBinding(tx, scope); + if ( + !bound || + bound.action.id !== intent.payload.sourceActionId || + intent.deliveryId !== bound.binding.deliveryId || + intent.principalId !== bound.binding.principalId + ) + return null; + const currentPayload = teamsBoardFileIntentPayload( + scope, + bound, + scope.comment.authorUserId, + ); + if ( + teamsFileProofHash(intent.payload) !== + teamsFileProofHash(currentPayload) + ) + return null; + source = bound; + causal = { + kind: "explicit_board_send", + intentId: intent.id, + ...currentPayload, + }; + } else { + const runId = scope.comment.createdByRunId; + if ( + !runId || + scope.comment.authorAgentId !== scope.endpoint.assignedAgentId || + scope.file.attachment.originatingRunId !== runId + ) + return null; + const [run] = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, scope.endpoint.companyId), + eq(heartbeatRuns.id, runId), + eq(heartbeatRuns.agentId, scope.endpoint.assignedAgentId), + eq(heartbeatRuns.nativeIssueId, scope.conversation.issueId), + eq(heartbeatRuns.runtimeMode, "native"), + eq(heartbeatRuns.status, "succeeded"), + isNotNull(heartbeatRuns.finishedAt), + ), + ) + .for("share", { noWait: true }); + const marker = run?.resultJson?.nativeCommittedChatResponse as + | Record + | undefined; + if ( + !run || + !marker || + marker.schema !== "paperclip.native_committed_chat_response.v1" || + typeof marker.resultId !== "string" || + !isUuidLike(marker.resultId) || + typeof marker.decisionId !== "string" || + !isUuidLike(marker.decisionId) || + typeof marker.canonicalSha256 !== "string" || + !/^(?:sha256:)?[a-f0-9]{64}$/.test(marker.canonicalSha256) + ) + return null; + // Pre-lock the exact causal source and actor rows before the existing + // complete native publication proof invokes its common authorization. + const originActions = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, "inbound_wakeup"), + inArray( + sql`${chatActions.payload}->>'commentId'`, + retryCommentIds(run.contextSnapshot ?? {}), + ), + ), + ); + const bindings: Array< + NonNullable>> + > = []; + for (const action of originActions) { + const binding = await teamsFileSourceBinding(tx, scope, action.id); + if (!binding) return null; + bindings.push(binding); + } + let proved: FailedChatRetrySource; + try { + proved = await failedChatRetrySource(tx, { + companyId: scope.endpoint.companyId, + issueId: scope.conversation.issueId, + agentId: run.agentId, + failedRunId: run.id, + publication: true, + committedResponse: { + resultId: marker.resultId, + canonicalSha256: marker.canonicalSha256, + decisionId: marker.decisionId, + continuingTeamsFileTransfer, + }, + }); + } catch (error) { + if ( + error instanceof HttpError && + (error.details as { code?: string } | undefined)?.code === + "chat_failed_run_retry_not_authorized" + ) + return null; + throw error; + } + if ( + proved.provider !== "microsoft-teams" || + proved.endpointId !== scope.endpoint.id || + proved.conversationId !== scope.conversation.id || + proved.sessionGeneration !== scope.conversation.sessionGeneration || + bindings.length !== proved.sources.length + ) + return null; + const ordered = proved.sources.map((entry) => + bindings.find((binding) => binding.action.id === entry.actionId), + ); + const first = ordered[0]; + if ( + !first || + ordered.some( + (entry) => + !entry || + entry.binding.principalId !== proved.principalId || + entry.binding.recipient.providerConversationId !== + first.binding.recipient.providerConversationId || + entry.binding.recipient.providerUserId !== + first.binding.recipient.providerUserId || + entry.binding.recipient.aadObjectId !== + first.binding.recipient.aadObjectId, + ) + ) + return null; + if ( + run.resultJson?.finalizationReasonCode === + "governed_response_waiting" && + !(await authorizeNativeChatReviewPresentation( + tx as unknown as Db, + { + companyId: scope.endpoint.companyId, + issueId: scope.conversation.issueId, + runId: run.id, + resultJson: run.resultJson, + destination: { + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + }, + }, + "nonblocking", + )) + ) + return null; + source = first; + causal = { + kind: "native_committed_response", + runId: run.id, + resultId: marker.resultId, + canonicalSha256: marker.canonicalSha256, + decisionId: marker.decisionId, + sourceScopeSha256: proved.sourceScopeSha256, + sources: ordered.map((entry) => entry!.sourceFacts), + }; + } + // No mutable stage, attempt, receipt, route URL or provider token enters + // this digest. The next authorization derives these facts afresh. + const sourceDigest = teamsFileProofHash({ + schema: "paperclip.teams.file-source.v1", + companyId: scope.endpoint.companyId, + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + conversationGeneration: scope.conversation.sessionGeneration, + issueId: scope.conversation.issueId, + publicationId: scope.publication.id, + commentId: scope.comment.id, + causal, + byteIdentity: scope.byteIdentity, + }); + if (!teamsFileRuntimeStillCurrent(scope)) return null; + return { + companyId: scope.endpoint.companyId, + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + issueId: scope.conversation.issueId, + publicationId: scope.publication.id, + commentId: scope.comment.id, + attachmentId: scope.file.attachment.id, + principalId: source.binding.principalId, + authorizedUserId: source.authorization.userId, + runtimeGeneration: scope.currentFence.generation, + credentialFingerprint: scope.currentFence.credentialFingerprint, + conversationGeneration: scope.conversation.sessionGeneration, + sourceDigest, + tenantId: source.binding.recipient.tenantId, + botAppId: source.binding.recipient.botAppId, + aadObjectId: source.binding.recipient.aadObjectId, + providerConversationId: source.binding.recipient.providerConversationId, + providerUserId: source.binding.recipient.providerUserId, + sha256: scope.file.asset.sha256, + byteSize: scope.file.asset.byteSize, + filename: scope.filename, + }; + } + return { stageTeamsBoardFileIntent, deriveTeamsFileTransferAuthority }; +} diff --git a/server/src/services/chat-channels/slack/file-receipts.ts b/server/src/services/chat-channels/slack/file-receipts.ts new file mode 100644 index 00000000000..a0cc84c2852 --- /dev/null +++ b/server/src/services/chat-channels/slack/file-receipts.ts @@ -0,0 +1,1000 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { + randomUUID, +} from "node:crypto"; +import { + and, + asc, + eq, + inArray, + isNull, + lte, + notExists, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointResources, + chatEndpoints, + chatMessageLinks, + chatPublications, +} from "@paperclipai/db"; + +import { + isUuidLike, +} from "@paperclipai/shared"; + +import { + type SlackFileUploadAcceptedReceipt, +} from "../../chat-sdk-runtime.js"; + +import { + stageSlackSessionSync, +} from "../../chat-slack-session-outbox.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type SlackFileUploadReceiptPayload = { + version: 1; + publicationId: string; + publicationAttempt: number; + threadId: string; + fileIds: string[]; + botExternalId: string; + runtimeGeneration: number; + credentialFingerprint: string; +}; + +function slackFileUploadReceiptPayload( + payload: Record, +): SlackFileUploadReceiptPayload | null { + if ( + payload.version !== 1 || + typeof payload.publicationId !== "string" || + !isUuidLike(payload.publicationId) || + typeof payload.publicationAttempt !== "number" || + !Number.isSafeInteger(payload.publicationAttempt) || + payload.publicationAttempt < 1 || + typeof payload.threadId !== "string" || + payload.threadId.length < 1 || + payload.threadId.length > 2_048 || + !payload.threadId.startsWith("slack:") || + typeof payload.botExternalId !== "string" || + payload.botExternalId.length < 1 || + payload.botExternalId.length > 255 || + typeof payload.runtimeGeneration !== "number" || + !Number.isSafeInteger(payload.runtimeGeneration) || + payload.runtimeGeneration < 0 || + typeof payload.credentialFingerprint !== "string" || + !/^[a-f0-9]{64}$/.test(payload.credentialFingerprint) || + !Array.isArray(payload.fileIds) || + payload.fileIds.length < 1 || + payload.fileIds.length > 20 || + payload.fileIds.some( + (fileId) => + typeof fileId !== "string" || !/^F[A-Z0-9]{1,254}$/.test(fileId), + ) || + new Set(payload.fileIds).size !== payload.fileIds.length + ) { + return null; + } + return payload as SlackFileUploadReceiptPayload; +} + +function operatorConfirmedSlackFileReceipt( + result: Record | null, + payload: SlackFileUploadReceiptPayload, +): boolean { + return ( + result?.operatorConfirmedPublicationId === payload.publicationId && + result.operatorConfirmedAttempt === payload.publicationAttempt && + typeof result.operatorConfirmedAt === "string" && + Number.isFinite(Date.parse(result.operatorConfirmedAt)) + ); +} + +function sameSlackFileUploadReceipt( + left: SlackFileUploadReceiptPayload, + right: SlackFileUploadReceiptPayload, +): boolean { + return ( + left.publicationId === right.publicationId && + left.publicationAttempt === right.publicationAttempt && + left.threadId === right.threadId && + left.botExternalId === right.botExternalId && + left.runtimeGeneration === right.runtimeGeneration && + left.credentialFingerprint === right.credentialFingerprint && + left.fileIds.length === right.fileIds.length && + left.fileIds.every((fileId, index) => fileId === right.fileIds[index]) + ); +} +const SLACK_FILE_RECEIPT_MAX_AGE_MS = 24 * 60 * 60_000; +const SLACK_FILE_RECEIPT_MAX_ATTEMPTS = 12; +const SLACK_FILE_RECEIPT_STALE_MS = 60_000; +type ConversationRow = typeof chatConversations.$inferSelect; +type SlackFileReceiptsOptions = { + endpointRecord: (id: string) => Promise; + withCredentialMutationLease: ReturnType; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + runtimeFor: (endpoint: EndpointRow) => Promise; + isExplicitOperatorPublication: (publication: typeof chatPublications.$inferSelect) => boolean; + recordExactOutboundPublicationLink: (tx: DbOrTransaction, input: { publication: typeof chatPublications.$inferSelect; providerMessageId: string }) => Promise; +}; + +export function createSlackFileReceipts(db: Db, options: SlackFileReceiptsOptions) { + const { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runtimeFor, isExplicitOperatorPublication, recordExactOutboundPublicationLink } = options; + + + async function recordSlackFileUploadReceipt(input: { + credentialLease: CredentialMutationLeaseGuard; + conversation: ConversationRow; + endpoint: EndpointRow; + publication: typeof chatPublications.$inferSelect; + receipt: SlackFileUploadAcceptedReceipt; + runtimeContext: LifecycleRuntimeFence; + }): Promise { + const publicationAttempt = input.publication.attempts + 1; + const botExternalId = input.endpoint.botExternalId; + const expectedThreadId = `slack:${input.receipt.channelId}:${input.receipt.threadTs ?? ""}`; + if ( + input.endpoint.provider !== "slack" || + !botExternalId || + input.receipt.version !== 1 || + expectedThreadId !== input.conversation.externalThreadId || + input.receipt.fileIds.length !== + (input.publication.payload.attachmentIds?.length ?? 0) + ) { + throw new Error( + "Slack file upload receipt did not match its publication", + ); + } + const payload: SlackFileUploadReceiptPayload = { + version: 1, + publicationId: input.publication.id, + publicationAttempt, + threadId: expectedThreadId, + fileIds: [...input.receipt.fileIds], + botExternalId, + runtimeGeneration: input.runtimeContext.generation, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + }; + if (!slackFileUploadReceiptPayload(payload)) { + throw new Error("Slack file upload receipt was malformed"); + } + const providerActionId = `slack-file-receipt:${input.publication.id}:${publicationAttempt}`; + const recordedAt = new Date(); + return db.transaction(async (tx) => { + await input.credentialLease.assertOwned(tx); + const publication = await tx + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + eq(chatPublications.id, input.publication.id), + eq(chatPublications.companyId, input.publication.companyId), + eq(chatPublications.endpointId, input.publication.endpointId), + eq( + chatPublications.conversationId, + input.publication.conversationId, + ), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, publicationAttempt), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const endpoint = publication + ? await runtimeCallbackEndpoint( + tx, + input.endpoint.id, + input.runtimeContext, + ["verifying", "active"], + ) + : null; + const conversation = endpoint + ? await tx + .select({ + externalThreadId: chatConversations.externalThreadId, + id: chatConversations.id, + }) + .from(chatConversations) + .where( + and( + eq(chatConversations.id, input.conversation.id), + eq(chatConversations.companyId, input.publication.companyId), + eq(chatConversations.endpointId, input.endpoint.id), + inArray(chatConversations.state, [ + "active", + "waiting", + "completed", + ]), + ), + ) + .for("no key update") + .then((rows) => rows[0] ?? null) + : null; + if ( + !publication || + endpoint?.provider !== "slack" || + endpoint.botExternalId !== botExternalId || + conversation?.externalThreadId !== expectedThreadId + ) { + throw new Error( + "Slack file upload ownership changed before receipt persistence", + ); + } + await notifyChatPublicationWork(tx); + const inserted = await tx + .insert(chatActions) + .values({ + companyId: input.publication.companyId, + endpointId: input.publication.endpointId, + conversationId: input.publication.conversationId, + kind: "slack_file_upload_receipt", + providerActionId, + payload, + status: "received", + result: { code: "slack_file_upload_identity_pending", attempts: 0 }, + createdAt: recordedAt, + updatedAt: recordedAt, + }) + .onConflictDoNothing() + .returning({ id: chatActions.id }); + if (inserted[0]) { + await input.credentialLease.assertOwned(tx); + return inserted[0].id; + } + const existing = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, input.publication.endpointId), + eq(chatActions.providerActionId, providerActionId), + eq(chatActions.kind, "slack_file_upload_receipt"), + sql`${chatActions.payload} = ${JSON.stringify(payload)}::jsonb`, + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!existing) { + throw new Error( + "Slack file upload receipt conflicted with durable state", + ); + } + await input.credentialLease.assertOwned(tx); + return existing.id; + }); + } + + async function stageSlackFilePublicationSuccess( + tx: DbTransaction, + input: { + committedAt: Date; + endpoint: EndpointRow; + publication: typeof chatPublications.$inferSelect; + providerMessageId: string; + runtimeContext: LifecycleRuntimeFence; + stageSessionSync: boolean; + }, + ): Promise { + if ( + !(await recordExactOutboundPublicationLink(tx, { + publication: input.publication, + providerMessageId: input.providerMessageId, + })) + ) { + throw new Error( + "Slack file upload message identity conflicts with another publication", + ); + } + await tx + .update(chatEndpoints) + .set({ + lastPublicationAt: input.committedAt, + updatedAt: input.committedAt, + }) + .where( + and( + eq(chatEndpoints.id, input.endpoint.id), + inArray(chatEndpoints.status, ["verifying", "active"]), + sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${input.runtimeContext.generation}`, + ), + ); + if (input.stageSessionSync) { + await stageSlackSessionSync(tx, { + companyId: input.publication.companyId, + endpointId: input.publication.endpointId, + conversationId: input.publication.conversationId, + runtimeGeneration: input.runtimeContext.generation, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + }); + } + } + + async function lockSlackFileReceiptAuthorization( + tx: DbTransaction, + input: { + actionId: string; + payload: SlackFileUploadReceiptPayload; + }, + ): Promise< + | { + action: typeof chatActions.$inferSelect; + conversation: ConversationRow; + endpoint: EndpointRow; + mode: "delivery_unknown" | "operator_confirmed"; + publication: typeof chatPublications.$inferSelect; + } + | { action: typeof chatActions.$inferSelect; mode: "defer" | "settled" } + | null + > { + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.id, input.payload.publicationId), + eq(chatPublications.attempts, input.payload.publicationAttempt), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const action = publication + ? await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.id, input.actionId), + eq(chatActions.companyId, publication.companyId), + eq(chatActions.endpointId, publication.endpointId), + eq(chatActions.conversationId, publication.conversationId), + eq(chatActions.kind, "slack_file_upload_receipt"), + eq( + chatActions.providerActionId, + `slack-file-receipt:${publication.id}:${publication.attempts}`, + ), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const currentPayload = action + ? slackFileUploadReceiptPayload(action.payload) + : null; + if ( + !publication || + !action || + !publication.payload.attachmentIds?.length || + publication.payload.card || + publication.payload.interactionId || + publication.idempotencyKey.startsWith("control:") || + !currentPayload || + !sameSlackFileUploadReceipt(currentPayload, input.payload) + ) { + return null; + } + if (publication.state === "streaming") { + return { action, mode: "defer" }; + } + if (publication.state === "published" && publication.providerMessageId) { + const exactLink = await tx + .select({ id: chatMessageLinks.id }) + .from(chatMessageLinks) + .where( + and( + eq(chatMessageLinks.companyId, publication.companyId), + eq(chatMessageLinks.endpointId, publication.endpointId), + eq(chatMessageLinks.conversationId, publication.conversationId), + eq(chatMessageLinks.publicationId, publication.id), + publication.commentId + ? eq(chatMessageLinks.commentId, publication.commentId) + : isNull(chatMessageLinks.commentId), + eq( + chatMessageLinks.providerMessageId, + publication.providerMessageId, + ), + eq(chatMessageLinks.direction, "outbound"), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + return exactLink ? { action, mode: "settled" } : null; + } + const mode = + publication.state === "delivery_unknown" + ? "delivery_unknown" + : publication.state === "published" && + !publication.providerMessageId && + operatorConfirmedSlackFileReceipt(action.result, input.payload) + ? "operator_confirmed" + : null; + if (!mode) return null; + const endpoint = await runtimeCallbackEndpoint( + tx, + publication.endpointId, + { + generation: input.payload.runtimeGeneration, + credentialFingerprint: input.payload.credentialFingerprint, + }, + ["verifying", "active"], + ); + const conversation = endpoint + ? await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.id, publication.conversationId), + eq(chatConversations.companyId, publication.companyId), + eq(chatConversations.endpointId, publication.endpointId), + eq(chatConversations.externalThreadId, input.payload.threadId), + inArray(chatConversations.state, [ + "active", + "waiting", + "completed", + ]), + ), + ) + .for("no key update") + .then((rows) => rows[0] ?? null) + : null; + const resource = + endpoint && conversation?.resourceId && !conversation.isDirectMessage + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.id, conversation.resourceId), + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + ), + ) + .for("no key update") + .then((rows) => rows[0] ?? null) + : null; + if ( + endpoint?.provider !== "slack" || + endpoint.botExternalId !== input.payload.botExternalId || + !conversation || + !(conversation.isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource)) + ) { + return null; + } + return { action, conversation, endpoint, mode, publication }; + } + + async function processPendingSlackFileUploadReceipts(limit = 25) { + const selectedAt = new Date(); + const actions = await db + .select() + .from(chatActions) + .where( + slackReceiptEligibility(selectedAt), + ) + .orderBy(asc(chatActions.updatedAt), asc(chatActions.id)) + .limit(limit); + let processed = 0; + for (const selected of actions) { + const payload = slackFileUploadReceiptPayload(selected.payload); + if (!payload) { + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { + code: "slack_file_upload_receipt_payload_invalid", + retryable: false, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.kind, selected.kind), + eq(chatActions.status, selected.status), + sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, + selected.result === null + ? isNull(chatActions.result) + : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, + ), + ); + }); + continue; + } + const record = await endpointRecord(selected.endpointId); + if (!record) { + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_file_upload_receipt_endpoint_removed" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.kind, selected.kind), + eq(chatActions.status, selected.status), + sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, + selected.result === null + ? isNull(chatActions.result) + : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, + ), + ); + }); + continue; + } + if (["paused", "attention"].includes(record.endpoint.status)) continue; + const ownerToken = randomUUID(); + let attempt = 1; + try { + await withCredentialMutationLease(record.endpoint, async (lease) => { + const claim = await db.transaction(async (tx) => { + await lease.assertOwned(tx); + const locked = await lockSlackFileReceiptAuthorization(tx, { + actionId: selected.id, + payload, + }); + const decisionAt = new Date(); + const priorAttempts = + typeof locked?.action.result?.attempts === "number" && + Number.isSafeInteger(locked.action.result.attempts) + ? locked.action.result.attempts + : 0; + attempt = priorAttempts + 1; + const retryAt = + typeof locked?.action.result?.retryAt === "string" + ? Date.parse(locked.action.result.retryAt) + : Number.NaN; + const eligible = locked + ? locked.action.status === "received" || + (locked.action.status === "failed" && + locked.action.result?.retryable === true && + Number.isFinite(retryAt) && + retryAt <= decisionAt.getTime()) || + (locked.action.status === "processing" && + locked.action.updatedAt.getTime() <= + decisionAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS) + : false; + if (locked && !eligible) return null; + if (locked?.mode === "defer") { + if (locked.action.status === "processing") { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "received", + result: { + code: "slack_file_upload_identity_pending", + attempts: priorAttempts, + }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, locked.action.id)); + } + return null; + } + if (locked?.mode === "settled") { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "processed", + result: { + code: "slack_file_upload_identity_confirmed", + attempts: priorAttempts, + }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, locked.action.id)); + return null; + } + if ( + !locked || + decisionAt.getTime() - + (locked?.action.createdAt ?? selected.createdAt).getTime() > + SLACK_FILE_RECEIPT_MAX_AGE_MS || + attempt > SLACK_FILE_RECEIPT_MAX_ATTEMPTS + ) { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + code: locked + ? "slack_file_upload_receipt_expired" + : "slack_file_upload_receipt_authorization_changed", + attempts: priorAttempts, + }, + updatedAt: decisionAt, + }) + .where( + and( + eq(chatActions.id, selected.id), + inArray(chatActions.status, [ + "received", + "failed", + "processing", + ]), + ), + ); + return null; + } + if ( + !["received", "failed", "processing"].includes( + locked.action.status, + ) + ) { + return null; + } + await notifyChatPublicationWork(tx); + const [claimed] = await tx + .update(chatActions) + .set({ + status: "processing", + result: { + attempts: attempt, + ownerToken, + ...(locked.mode === "operator_confirmed" + ? { + operatorConfirmedPublicationId: + locked.action.result?.operatorConfirmedPublicationId, + operatorConfirmedAttempt: + locked.action.result?.operatorConfirmedAttempt, + operatorConfirmedAt: + locked.action.result?.operatorConfirmedAt, + } + : {}), + }, + updatedAt: decisionAt, + }) + .where( + and( + eq(chatActions.id, locked.action.id), + eq(chatActions.status, locked.action.status), + ), + ) + .returning({ id: chatActions.id }); + await lease.assertOwned(tx); + return claimed ? locked : null; + }); + if (!claim || !("endpoint" in claim)) return; + processed += 1; + const endpointRuntime = await runtimeFor(claim.endpoint); + await lease.assertOwned(); + const providerMessageId = + await endpointRuntime.resolveSlackFileUploadReceipt( + payload.threadId, + payload.fileIds, + ); + if (!providerMessageId) { + await db.transaction(async (tx) => { + await lease.assertOwned(tx); + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "failed", + result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( + { + attempts: attempt, + code: "slack_file_upload_identity_pending", + retryable: true, + retryAt: new Date( + Date.now() + Math.min(60_000, 1_000 * 2 ** attempt), + ).toISOString(), + }, + )}::jsonb`, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + }); + return; + } + await db.transaction(async (tx) => { + await lease.assertOwned(tx); + const locked = await lockSlackFileReceiptAuthorization(tx, { + actionId: selected.id, + payload, + }); + if ( + !locked || + locked.mode === "defer" || + locked.mode === "settled" || + !("publication" in locked) || + locked.action.status !== "processing" || + locked.action.result?.ownerToken !== ownerToken + ) { + if (!locked) { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + attempts: attempt, + code: "slack_file_upload_receipt_authorization_changed", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + } else if (locked.mode === "defer") { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "received", + result: { + attempts: attempt, + code: "slack_file_upload_identity_pending", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + } else { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: + locked.mode === "settled" ? "processed" : "cancelled", + result: { + attempts: attempt, + code: + locked.mode === "settled" + ? "slack_file_upload_identity_confirmed" + : "slack_file_upload_receipt_superseded", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + } + return; + } + const committedAt = new Date(); + if ( + !(await recordExactOutboundPublicationLink(tx, { + publication: locked.publication, + providerMessageId, + })) + ) { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "failed", + result: { + attempts: attempt, + code: "slack_file_upload_receipt_message_conflict", + retryable: false, + }, + updatedAt: committedAt, + }) + .where(eq(chatActions.id, selected.id)); + return; + } + if (locked.mode === "delivery_unknown") { + await notifyChatPublicationWork(tx); + const [settled] = await tx + .update(chatPublications) + .set({ + state: "published", + providerMessageId, + publishedAt: committedAt, + nextAttemptAt: null, + redactedError: null, + updatedAt: committedAt, + }) + .where( + and( + eq(chatPublications.id, locked.publication.id), + eq(chatPublications.state, "delivery_unknown"), + eq(chatPublications.attempts, payload.publicationAttempt), + ), + ) + .returning({ id: chatPublications.id }); + if (!settled) { + throw new Error( + "Slack file upload publication changed before receipt commit", + ); + } + await stageSlackFilePublicationSuccess(tx, { + committedAt, + endpoint: locked.endpoint, + publication: locked.publication, + providerMessageId, + runtimeContext: { + generation: payload.runtimeGeneration, + credentialFingerprint: payload.credentialFingerprint, + }, + stageSessionSync: + !isExplicitOperatorPublication(locked.publication) && + !locked.publication.idempotencyKey.startsWith( + "control:status:", + ), + }); + } else { + await notifyChatPublicationWork(tx); + const [enriched] = await tx + .update(chatPublications) + .set({ providerMessageId }) + .where( + and( + eq(chatPublications.id, locked.publication.id), + eq(chatPublications.state, "published"), + eq(chatPublications.attempts, payload.publicationAttempt), + isNull(chatPublications.providerMessageId), + ), + ) + .returning({ id: chatPublications.id }); + if (!enriched) { + throw new Error( + "Slack file upload publication changed before identity enrichment", + ); + } + } + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "processed", + result: { + attempts: attempt, + code: "slack_file_upload_identity_confirmed", + }, + updatedAt: committedAt, + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + await lease.assertOwned(tx); + }); + }); + } catch (error) { + const disposition = classifyChatPublicationError(error, attempt); + const retryable = + disposition.kind === "retry" || + disposition.kind === "delivery_unknown"; + const retryMs = + disposition.kind === "retry" + ? disposition.retryAfterMs + : Math.min(60_000, 1_000 * 2 ** Math.min(attempt, 8)); + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatActions) + .set({ + status: retryable ? "failed" : "cancelled", + result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( + { + attempts: attempt, + code: retryable + ? "slack_file_upload_receipt_lookup_retry" + : "slack_file_upload_receipt_lookup_rejected", + retryable, + ...(retryable + ? { retryAt: new Date(Date.now() + retryMs).toISOString() } + : {}), + }, + )}::jsonb`, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + }); + } + } + return processed; + } + + function slackReceiptEligibility(selectedAt?: Date) { + return and( + eq(chatActions.kind, "slack_file_upload_receipt"), + notExists( + db + .select({ id: chatEndpoints.id }) + .from(chatEndpoints) + .where( + and( + eq(chatEndpoints.id, chatActions.endpointId), + inArray(chatEndpoints.status, ["paused", "attention"]), + ), + ), + ), + // The normal publication owner is still polling Slack. Receipt + // recovery must neither contend with it nor let its not-yet-due + // action occupy the bounded recovery page. + notExists( + db + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + sql`${chatPublications.id}::text = ${chatActions.payload}->>'publicationId'`, + sql`${chatPublications.attempts}::text = ${chatActions.payload}->>'publicationAttempt'`, + eq(chatPublications.state, "streaming"), + ), + ), + ), + or( + eq(chatActions.status, "received"), + and( + eq(chatActions.status, "failed"), + sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'true'`, + selectedAt ? sql`(${chatActions.result}->>'retryAt')::timestamptz <= ${selectedAt.toISOString()}::timestamptz` : sql`${chatActions.result}->>'retryAt' is not null`, + ), + and( + eq(chatActions.status, "processing"), + selectedAt ? lte(chatActions.updatedAt, new Date(selectedAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS)) : undefined, + ), + ), + ); + } + + async function nextSlackReceiptAt(): Promise { + const [row] = await db.select({ at: sql`min(case + when ${chatActions.status} = 'processing' then ${chatActions.updatedAt} + ${SLACK_FILE_RECEIPT_STALE_MS} * interval '1 millisecond' + when ${chatActions.status} = 'failed' then (${chatActions.result}->>'retryAt')::timestamptz + else now() end)` }).from(chatActions).where(slackReceiptEligibility()); + return row?.at == null ? null : new Date(row.at).getTime(); + } + return { recordSlackFileUploadReceipt, stageSlackFilePublicationSuccess, processPendingSlackFileUploadReceipts, nextSlackReceiptAt }; +} diff --git a/server/src/services/chat-channels/slack/session-status.ts b/server/src/services/chat-channels/slack/session-status.ts new file mode 100644 index 00000000000..ccdf67e8412 --- /dev/null +++ b/server/src/services/chat-channels/slack/session-status.ts @@ -0,0 +1,408 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { dueChatAction } from "../../chat-action-work.js"; + +import { notifyChatActionWork } from "../../chat-work-notifications.js"; + +import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js"; + +import { + randomUUID, +} from "node:crypto"; +import { + and, + asc, + desc, + eq, + gt, + inArray, + lte, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointResources, + chatEndpoints, + chatPublications, + heartbeatRuns, +} from "@paperclipai/db"; + +import { logger } from "../../../middleware/logger.js"; + +import { + setSlackSessionStatus, + slackSessionStatusForPublication, +} from "../../chat-slack-sessions.js"; +import { + slackSessionSyncPayload, +} from "../../chat-slack-session-outbox.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type SlackSessionStatusOptions = { + endpointRecord: (id: string) => Promise; + withCredentialMutationLease: ReturnType; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + runIdFromMilestonePublication: (publication: typeof chatPublications.$inferSelect) => string | null; + resolveCredentials: (endpoint: EndpointRow, database?: DbOrTransaction) => Promise>; + fetchImpl: typeof globalThis.fetch; + redactError: (error: unknown) => string; + slackSessionSyncSelectionBarrier?: () => Promise; +}; + +export function createSlackSessionStatus(db: Db, options: SlackSessionStatusOptions) { + const { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runIdFromMilestonePublication, resolveCredentials, fetchImpl, redactError } = options; + + + async function processPendingSlackSessionSyncs( + limit = 25, + onlyActionId?: string, + ) { + const now = new Date(); + const actions = await db + .select({ action: chatActions }) + .from(chatActions) + .leftJoin(chatEndpoints, and(eq(chatEndpoints.id, chatActions.endpointId), eq(chatEndpoints.companyId, chatActions.companyId))) + .where(and( + onlyActionId ? eq(chatActions.id, onlyActionId) : undefined, + dueChatAction("slack_session_sync", now.getTime()), + )) + .orderBy(asc(chatActions.updatedAt)) + .limit(limit) + .then(rows => rows.map(row => row.action)); + await options.slackSessionSyncSelectionBarrier?.(); + for (const action of actions) { + const payload = slackSessionSyncPayload(action.payload); + if (!payload || !action.conversationId) { + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "slack_session_sync"); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { code: "slack_session_payload_invalid" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.updatedAt, action.updatedAt), + ), + ); + }); + continue; + } + const revisionWhere = and( + eq(chatActions.id, action.id), + sql`(${chatActions.payload}->>'revision')::bigint = ${payload.revision}`, + ); + const selectedSnapshotWhere = and( + revisionWhere, + eq(chatActions.status, action.status), + eq(chatActions.updatedAt, action.updatedAt), + ); + const record = await endpointRecord(action.endpointId); + if (record && ["paused", "attention"].includes(record.endpoint.status)) continue; + if (!record || record.endpoint.provider !== "slack") { + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "slack_session_sync"); + return tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_session_endpoint_unavailable" }, + updatedAt: new Date(), + }) + .where(selectedSnapshotWhere); + }); + continue; + } + const attempts = + (typeof action.result?.attempts === "number" + ? action.result.attempts + : 0) + 1; + const ownerToken = randomUUID(); + let ownsAttempt = false; + try { + await withCredentialMutationLease(record.endpoint, async (lease) => { + const claim = await db.transaction(async (tx) => { + await lease.assertOwned(tx); + const endpoint = await runtimeCallbackEndpoint( + tx, + action.endpointId, + { + generation: payload.runtimeGeneration, + credentialFingerprint: payload.credentialFingerprint, + }, + ["active", "verifying"], + ); + const conversation = endpoint + ? await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.id, action.conversationId!), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.companyId, endpoint.companyId), + inArray(chatConversations.state, [ + "active", + "waiting", + "completed", + ]), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const resource = + endpoint && conversation?.resourceId + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.id, conversation.resourceId), + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const superseded = conversation + ? await tx + .select({ id: chatConversations.id }) + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, conversation.companyId), + eq(chatConversations.endpointId, conversation.endpointId), + eq( + chatConversations.externalThreadId, + conversation.externalThreadId, + ), + gt( + chatConversations.sessionGeneration, + conversation.sessionGeneration, + ), + ), + ) + .limit(1) + .then((rows) => rows.length > 0) + : false; + if ( + !endpoint || + !conversation || + superseded || + !(conversation.isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource)) + ) { + await notifyChatActionWork(tx, "slack_session_sync"); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_session_destination_changed" }, + updatedAt: new Date(), + }) + .where(selectedSnapshotWhere); + return null; + } + // Recompute at dispatch: a delayed processing retry must not + // overwrite a newer final response or a later question card. + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq(chatPublications.conversationId, conversation.id), + eq(chatPublications.state, "published"), + sql`${chatPublications.idempotencyKey} not like 'explicit%'`, + sql`${chatPublications.idempotencyKey} not like 'control:status:%'`, + sql`${chatPublications.idempotencyKey} not like 'wake:%'`, + ), + ) + .orderBy( + desc(chatPublications.publishedAt), + desc(chatPublications.createdAt), + desc(chatPublications.id), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!publication) { + await notifyChatActionWork(tx, "slack_session_sync"); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_session_no_published_state" }, + updatedAt: new Date(), + }) + .where(selectedSnapshotWhere); + return null; + } + let status = slackSessionStatusForPublication( + publication.payload, + conversation.state === "completed", + ); + const runId = runIdFromMilestonePublication(publication); + if (status === "processing" && runId) { + const run = await tx + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, runId), + eq(heartbeatRuns.companyId, endpoint.companyId), + eq(heartbeatRuns.agentId, endpoint.assignedAgentId), + ), + ) + .then((rows) => rows[0] ?? null); + // Stop may finish before its confirmation publication. Never + // resurrect processing from that run's earlier working receipt. + if (!run || !["queued", "running"].includes(run.status)) + status = "active"; + } + await notifyChatActionWork(tx, "slack_session_sync"); + const claimed = await tx + .update(chatActions) + .set({ + status: "processing", + result: { attempts, ownerToken }, + updatedAt: new Date(), + }) + .where( + and( + selectedSnapshotWhere, + or( + and( + eq(chatActions.status, "received"), + sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${new Date().toISOString()}::timestamptz)`, + ), + and( + eq(chatActions.status, "processing"), + lte(chatActions.updatedAt, new Date(Date.now() - 60_000)), + ), + ), + ), + ) + .returning({ id: chatActions.id }); + await lease.assertOwned(tx); + return claimed.length ? { endpoint, conversation, status } : null; + }); + if (!claim) return; + ownsAttempt = true; + const credentials = await resolveCredentials(claim.endpoint); + await lease.assertOwned(); + const outcome = await setSlackSessionStatus({ + botToken: credentials.botToken, + threadId: claim.conversation.externalThreadId, + status: claim.status, + fetch: fetchImpl, + }); + await db.transaction(async (tx) => { + await lease.assertOwned(tx); + // Refresh long work before Slack's one-hour processing timeout. + // Unsupported sessions settle until a new publication restages + // them; completed threads must not create perpetual polling work. + const retryMs = + outcome === "updated" && claim.status === "processing" + ? 30 * 60_000 + : null; + await notifyChatActionWork(tx, "slack_session_sync"); + await tx + .update(chatActions) + .set({ + status: retryMs === null ? "processed" : "received", + result: { + attempts, + outcome, + sessionStatus: claim.status, + ...(retryMs === null + ? {} + : { + retryAt: new Date(Date.now() + retryMs).toISOString(), + }), + }, + updatedAt: new Date(), + }) + .where( + and( + revisionWhere, + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + }); + }); + } catch (error) { + const disposition = classifyChatPublicationError(error, attempts); + // Unlike posting a message, repeating setStatus cannot duplicate any + // content. Ambiguous transport/receipt failures remain retryable, but + // a definite scope/auth/destination rejection waits for new activity. + const retryable = + disposition.kind === "retry" || + disposition.kind === "delivery_unknown"; + const retryMs = + disposition.kind === "retry" + ? disposition.retryAfterMs + : Math.min(5 * 60_000, 1_000 * 2 ** Math.min(attempts, 8)); + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "slack_session_sync"); + return tx + .update(chatActions) + .set({ + status: retryable ? "received" : "failed", + result: { + attempts, + code: retryable + ? "slack_session_sync_retry" + : "slack_session_sync_rejected", + ...(retryable + ? { retryAt: new Date(Date.now() + retryMs).toISOString() } + : {}), + }, + updatedAt: new Date(), + }) + .where( + and( + revisionWhere, + ownsAttempt + ? and( + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ) + : and( + eq(chatActions.status, "received"), + eq(chatActions.updatedAt, action.updatedAt), + ), + ), + ); + }); + logger.warn( + { + endpointId: action.endpointId, + actionId: action.id, + retryable, + error: redactError(error), + }, + "Slack session status update did not complete; message delivery is unchanged", + ); + } + } + return actions.length; + } + return { processPendingSlackSessionSyncs }; +} diff --git a/server/src/services/chat-channels/telegram/publication-drafts.ts b/server/src/services/chat-channels/telegram/publication-drafts.ts new file mode 100644 index 00000000000..c469155c736 --- /dev/null +++ b/server/src/services/chat-channels/telegram/publication-drafts.ts @@ -0,0 +1,461 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { type ChatRuntime } from "../../chat-runtime.js"; +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { + and, + eq, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatPublications, + toolConnections, +} from "@paperclipai/db"; +import type { + ToolCredentialSecretRef, +} from "@paperclipai/shared"; + +import { + TELEGRAM_DRAFT_ACTION_KIND, + TELEGRAM_DRAFT_STOPPED_REASON, + parseTelegramDraftBinding, + telegramDraftTextSha256, + telegramGenerationStoppedReceipt, + telegramPrivateDraftDestination, + type TelegramDraftControl, + type TelegramGenerationStoppedProof, +} from "../../chat-telegram-draft-stop.js"; + +import { + type ChatSdkCallbackEvent, + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; + +import { + renderPublicationTransportText, +} from "../../chat-publication-text-parts.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type ConversationRow = typeof chatConversations.$inferSelect; +type TelegramPublicationDraftsOptions = { + runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + runtimeFor: (endpoint: EndpointRow) => Promise; + runtime: Pick; + runtimeContexts: WeakMap; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + authorizeRetainedChatSourcePublication: (tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect) => Promise; +}; + +export function createTelegramPublicationDrafts(db: Db, options: TelegramPublicationDraftsOptions) { + const { runtimeCallbackRecord, runtimeCallbackEndpoint, runtimeFor, runtime, runtimeContexts, credentialFingerprint, authorizeRetainedChatSourcePublication } = options; + + + async function handleTelegramGenerationStopped( + callback: ChatSdkCallbackEvent, + context: RuntimeContext, + ): Promise { + const receipt = telegramGenerationStoppedReceipt(callback.event); + if (callback.provider !== "telegram" || !receipt) return; + const record = await runtimeCallbackRecord(callback.endpointId, context, [ + "verifying", + "active", + ]); + if ( + record?.endpoint.provider !== "telegram" || + record.endpoint.botExternalId !== receipt.botUserId + ) + return; + const candidates = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, record.endpoint.companyId), + eq(chatActions.endpointId, callback.endpointId), + eq(chatActions.kind, TELEGRAM_DRAFT_ACTION_KIND), + sql`${chatActions.payload}->>'botUserId' = ${receipt.botUserId}`, + sql`${chatActions.payload}->>'draftId' = ${String(receipt.draftId)}`, + ), + ) + .limit(2); + if (candidates.length !== 1) return; + const candidate = candidates[0]!; + const binding = parseTelegramDraftBinding(candidate.payload); + if ( + !binding || + binding.chatId !== receipt.chatId || + binding.messageThreadId !== receipt.messageThreadId || + binding.runtimeGeneration !== context.generation || + binding.credentialFingerprint !== context.credentialFingerprint + ) + return; + await db.transaction(async (tx) => { + // The sender holds a renewable credential lease during draft HTTP. Do + // not acquire that lease here: a Stop can arrive inside the first RPC. + // Both Stop and final-send use the publication→endpoint→draft lock order. + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, record.endpoint.companyId), + eq(chatPublications.endpointId, callback.endpointId), + eq(chatPublications.conversationId, binding.conversationId), + eq(chatPublications.id, binding.publicationId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const endpoint = await runtimeCallbackEndpoint( + tx, + callback.endpointId, + context, + ["verifying", "active"], + ); + if ( + !publication || + !endpoint || + endpoint.provider !== "telegram" || + endpoint.botExternalId !== receipt.botUserId || + runtime.get(endpoint.id) !== context.endpointRuntime || + publication.attempts !== binding.publicationAttempt || + telegramDraftTextSha256( + renderPublicationTransportText(publication.payload), + ) !== binding.textSha256 + ) + return; + const connection = await tx + .select() + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .for("share") + .then((rows) => rows[0] ?? null); + if ( + !connection?.enabled || + connection.status !== "active" || + credentialFingerprint(connection.credentialSecretRefs) !== + binding.credentialFingerprint + ) + return; + const conversation = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, binding.conversationId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + !conversation || + !conversation.isDirectMessage || + conversation.sessionGeneration !== binding.sessionGeneration || + conversation.externalThreadId !== + `telegram:${binding.chatId}${binding.messageThreadId === null ? "" : `:${binding.messageThreadId}`}` + ) + return; + const action = await tx + .select() + .from(chatActions) + .where(eq(chatActions.id, candidate.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + !action || + JSON.stringify(action.payload) !== JSON.stringify(candidate.payload) + ) + return; + // final_sending is an irreversible local I/O claim, not a delivery + // receipt. A late Stop must not claim that an in-flight final was undone. + if ( + action.result?.phase !== "drafting" || + action.status !== "processing" || + !["streaming", "delivery_unknown"].includes(publication.state) + ) + return; + const stoppedAt = new Date(); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + phase: "stopped", + updateId: receipt.updateId, + stoppedAt: stoppedAt.toISOString(), + }, + updatedAt: stoppedAt, + }) + .where(eq(chatActions.id, action.id)); + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ + state: "cancelled", + nextAttemptAt: null, + redactedError: TELEGRAM_DRAFT_STOPPED_REASON, + updatedAt: stoppedAt, + }) + .where(eq(chatPublications.id, publication.id)); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + code: "telegram_draft_presentation_stopped", + attempts: publication.attempts, + }, + updatedAt: stoppedAt, + }) + .where( + and( + eq(chatActions.companyId, publication.companyId), + eq(chatActions.endpointId, publication.endpointId), + eq( + chatActions.providerActionId, + `task-control-authorization:${publication.id}`, + ), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'attempts' = ${String(publication.attempts)}`, + ), + ); + }); + } + + async function reserveTelegramPublicationDraft(input: { + endpoint: EndpointRow; + conversation: ConversationRow; + publication: typeof chatPublications.$inferSelect; + text: string; + credentialLease: CredentialMutationLeaseGuard; + runtimeContext: RuntimeContext; + }): Promise<{ actionId: string; control: TelegramDraftControl }> { + const endpointRuntime = await runtimeFor(input.endpoint); + const registeredContext = runtimeContexts.get(endpointRuntime as object); + if ( + !registeredContext || + registeredContext.generation !== input.runtimeContext.generation || + registeredContext.credentialFingerprint !== + input.runtimeContext.credentialFingerprint || + registeredContext.version !== input.runtimeContext.version + ) + throw new Error("Telegram draft runtime authority changed"); + const draftRuntimeContext = { ...input.runtimeContext, endpointRuntime }; + const destination = telegramPrivateDraftDestination( + input.conversation.externalThreadId, + ); + if ( + !destination || + !input.conversation.isDirectMessage || + !input.endpoint.botExternalId + ) + throw new Error("Telegram private draft destination is invalid"); + const bindingBase = { + version: 1 as const, + publicationId: input.publication.id, + publicationAttempt: input.publication.attempts + 1, + conversationId: input.conversation.id, + sessionGeneration: input.conversation.sessionGeneration, + runtimeGeneration: input.runtimeContext.generation, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + botUserId: input.endpoint.botExternalId, + ...destination, + textSha256: telegramDraftTextSha256(input.text), + }; + const reserved = await db.transaction(async (tx) => { + await input.credentialLease.assertOwned(tx); + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.id, input.publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, bindingBase.publicationAttempt), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!publication) + throw new Error("Telegram draft publication ownership changed"); + const providerActionId = `telegram-draft-publication:${publication.id}:${publication.attempts}`; + const existing = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, input.endpoint.id), + eq(chatActions.providerActionId, providerActionId), + ), + ) + .limit(1); + // A second invocation of the same attempt cannot create another draft or + // replay an uncertain final. A new explicit publication attempt gets a + // fresh, never-before-used bot-global ID. + if (existing.length) + throw new Error("Telegram draft attempt already exists"); + // NO CYCLE and no company/endpoint ownership: rollback, endpoint deletion + // and bot re-binding cannot recycle a provider-visible draft identity. + // Exhaustion is a refusal, never a fallback to a process-local/random ID. + const allocated = await tx.execute( + sql`select nextval('chat_telegram_draft_ids')::integer as "draftId"`, + ); + const binding = parseTelegramDraftBinding({ + ...bindingBase, + draftId: allocated[0]?.draftId, + }); + if (!binding) + throw new Error("Telegram draft identity allocation was invalid"); + const [action] = await tx + .insert(chatActions) + .values({ + companyId: input.endpoint.companyId, + endpointId: input.endpoint.id, + conversationId: input.conversation.id, + kind: TELEGRAM_DRAFT_ACTION_KIND, + providerActionId, + payload: binding, + status: "issued", + result: { phase: "reserved" }, + }) + .returning(); + return { action: action!, binding }; + }); + const before = (final: boolean): Promise => + db.transaction(async (tx) => { + await input.credentialLease.assertOwned(tx); + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, input.endpoint.companyId), + eq(chatPublications.id, input.publication.id), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const endpoint = await runtimeCallbackEndpoint( + tx, + input.endpoint.id, + draftRuntimeContext, + ["verifying", "active"], + ); + if ( + !endpoint || + endpoint.provider !== "telegram" || + !endpoint.allowDirectMessages || + endpoint.botExternalId !== reserved.binding.botUserId || + runtime.get(endpoint.id) !== endpointRuntime + ) + throw new Error("Telegram draft endpoint authority changed"); + const connection = await tx + .select() + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .for("share") + .then((rows) => rows[0] ?? null); + if ( + !connection?.enabled || + connection.status !== "active" || + credentialFingerprint(connection.credentialSecretRefs) !== + reserved.binding.credentialFingerprint + ) + throw new Error("Telegram draft credential authority changed"); + const conversation = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, input.conversation.id), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const action = await tx + .select() + .from(chatActions) + .where(eq(chatActions.id, reserved.action.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + !action || + JSON.stringify(action.payload) !== + JSON.stringify(reserved.action.payload) || + !publication || + publication.attempts !== reserved.binding.publicationAttempt + ) + throw new Error("Telegram draft attempt authority changed"); + if ( + action.status === "cancelled" && + action.result?.phase === "stopped" && + publication.state === "cancelled" + ) + return false; + if ( + publication.state !== "streaming" || + !conversation || + telegramDraftTextSha256( + renderPublicationTransportText(publication.payload), + ) !== reserved.binding.textSha256 || + conversation.sessionGeneration !== + reserved.binding.sessionGeneration || + conversation.externalThreadId !== + input.conversation.externalThreadId || + !["active", "waiting", "completed"].includes(conversation.state) || + !["reserved", "drafting"].includes(String(action.result?.phase)) || + !["issued", "processing"].includes(action.status) || + !(await authorizeRetainedChatSourcePublication(tx, publication)) + ) + throw new Error("Telegram draft source authority changed"); + await tx + .update(chatActions) + .set({ + status: "processing", + result: { + phase: final ? "final_sending" : "drafting", + }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, action.id)); + await input.credentialLease.assertOwned(tx); + return true; + }); + return { + actionId: reserved.action.id, + control: { + version: 1, + draftId: reserved.binding.draftId, + beforeDraft: () => before(false), + beforeFinal: () => before(true), + }, + }; + } + return { handleTelegramGenerationStopped, reserveTelegramPublicationDraft }; +}