mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Chat channels connect that work to provider conversations. > - One shared service contains callback, delivery, reaction, and setup logic. > - These responsibilities have distinct contracts and existing public-path tests. > - This PR moves ten cohesive responsibilities into focused modules. > - The service retains shared runtime ownership and scheduling. > - Provider behavior is easier to locate and review. ## Linked Issues or Issue Description Refs #15995, #16026, #16027, #16030, #16031, #16035, #16037, #16038, #16041, #16043. This PR consolidates the reviewed extraction stack. The plan is `doc/plans/2026-10-12-chat-channel-delivery-boundaries-refactor.md`. ## What Changed - Move Slack callback verification into the Slack folder. - Move Discord Gateway events and root-thread admission/recovery into the Discord folder. - Extract durable receipt reactions and inbound reaction admission/replay. - Extract inbound attachment ingestion and outbound integrity-checked hydration. Preserve the public hydration export. - Extract durable provider effects. Keep Telegram callback notices in the Telegram folder. - Extract conversation/activity reads and setup qualification. Keep Slack and Teams projections in their channel folders. - Preserve the merged managed Slack setup behavior, shared workers, credential leases, and transaction boundaries. - Reduce `chat-channels.ts` from 26,493 lines / 1,048,769 bytes to 21,460 lines / 862,284 bytes. This removes 5,033 lines from the service. ## Verification - Each extraction passed its selected existing tests before and after through public service paths. - On updated master, all 55 selected Slack cases passed before extraction. All 194 selected chat/managed-Slack cases passed on the final stack tip. - `pnpm -r typecheck`, `pnpm build`, module-boundary checks, and whitespace checks passed on that tip. - The normalized syntax audit verifies 69 moved declarations and all retained statements, including their relative order, against updated master. - The eight earlier stack merges retain the exact validated repository tree. The combined root equals the validated tip merged with current master. All chat-channel files remain byte-identical to the validated tip. Current combined-root CI and review are pending. - Full local verification remains unverified. The unchanged `workspace-git-snapshot-streaming.test.ts:56` stress test timed out at 300,000 ms in a clean run and again in isolation. The full run was stopped after the reproduced failure. No timeout or assertion was weakened. - These checks use assembled services, real local databases, and provider fixtures. They do not prove live third-party execution. Reviewers can run the public-path suites: ```sh pnpm exec vitest run server/src/__tests__/chat-channels.integration.test.ts server/src/services/chat-outbound-attachment-hydration.test.ts server/src/__tests__/slack-development-manager.test.ts ``` ## Risks - Factory wiring must preserve live values, shared state, and initialization order. Identity-link construction retains deferred access to the provider-effects callback. - Credential fences, authorization transactions, and delivery settlement must keep their original ordering. Syntax parity and public-path integration cases cover these boundaries. - Full local verification has the recorded Git-streaming timeout. The combined PR must pass master’s required CI before merging into master. ## Model Used OpenAI Codex, GPT-6 family, with reasoning and repository tools. The exact runtime model ID and context-window size are not exposed in this session. No separate agent was used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
675 lines
26 KiB
TypeScript
675 lines
26 KiB
TypeScript
import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "./runtime-types.js";
|
|
|
|
import type { ChatEndpointRuntime } from "../voice/voice-runtime.js";
|
|
|
|
import { takePhotonCompanion } from "../photon/attachments.js";
|
|
|
|
import { HEIF_CONTENT_TYPES, photonHeifPreview, validatePhotonImage } from "../photon/media.js";
|
|
|
|
import { PhotonError } from "../photon/cloud.js";
|
|
|
|
import {
|
|
createHash,
|
|
} from "node:crypto";
|
|
import {
|
|
and,
|
|
eq,
|
|
inArray,
|
|
isNull,
|
|
ne,
|
|
or,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
|
|
import type { Db } from "@paperclipai/db";
|
|
|
|
import {
|
|
assets,
|
|
chatActions,
|
|
chatConversations,
|
|
chatDeliveries,
|
|
issueComments,
|
|
issueAttachments,
|
|
} from "@paperclipai/db";
|
|
|
|
import {
|
|
isAllowedContentType,
|
|
MAX_ATTACHMENT_BYTES,
|
|
normalizeContentType,
|
|
normalizeUploadAttachmentContentType,
|
|
} from "../../attachment-types.js";
|
|
|
|
import {
|
|
hasTelegramMediaProvenance,
|
|
identifyTelegramMedia,
|
|
telegramMediaNeedsIdentification,
|
|
} from "../chat-telegram-media-intake.js";
|
|
|
|
import {
|
|
forbidden,
|
|
} from "../../errors.js";
|
|
import { logger } from "../../middleware/logger.js";
|
|
|
|
import type { StorageService } from "../../storage/types.js";
|
|
|
|
import { issueService } from "../issues.js";
|
|
|
|
import { isExternalChatWaitAuthorizationContention } from "../native-runtime/chat-attachment-reuse.js";
|
|
|
|
import {
|
|
GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS,
|
|
GitHubAttachmentUnavailableError,
|
|
prepareGitHubPublicAttachment,
|
|
} from "../chat-github-attachments.js";
|
|
|
|
import type {
|
|
Attachment,
|
|
} from "chat";
|
|
|
|
type DeliveryRow = typeof chatDeliveries.$inferSelect;
|
|
|
|
type InboundAttachmentsOptions = {
|
|
storage?: StorageService;
|
|
authorizeInboundWakeup: (tx: DbOrTransaction, action: typeof chatActions.$inferSelect) => Promise<{ delivery: DeliveryRow; conversation: typeof chatConversations.$inferSelect }>;
|
|
lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null;
|
|
sanitizeFilename: (value: string | undefined) => string | null;
|
|
issuesSvc: Pick<ReturnType<typeof issueService>, "createAttachment">;
|
|
isExternalActionAuthorizationChange: (error: unknown) => boolean;
|
|
redactError: (error: unknown) => string;
|
|
};
|
|
|
|
export function createInboundAttachments(db: Db, options: InboundAttachmentsOptions) {
|
|
const { authorizeInboundWakeup, lifecycleRuntimeFence, sanitizeFilename, issuesSvc, isExternalActionAuthorizationChange, redactError } = options;
|
|
|
|
|
|
async function ingestAttachments(input: {
|
|
endpoint: EndpointRow;
|
|
endpointRuntime: ChatEndpointRuntime;
|
|
deliveryId: string;
|
|
issueId: string;
|
|
issueCommentId: string;
|
|
attachments: Attachment[];
|
|
attachmentLimitOmissions?: number;
|
|
unavailableReferenceCount?: number;
|
|
actorUserId: string | null;
|
|
}): Promise<{
|
|
storedIds: string[];
|
|
omissionReasons: Record<string, number>;
|
|
}> {
|
|
const omissionReasons: Record<string, number> = {};
|
|
const omit = (reason: string, count = 1) => {
|
|
omissionReasons[reason] = (omissionReasons[reason] ?? 0) + count;
|
|
};
|
|
const boundedAttachments = input.attachments.slice(0, 20);
|
|
const photonDerivatives = new Map<Attachment, { source: Attachment; sourceHash: string; kind: "heif_jpeg_preview" | "live_photo_video" }>();
|
|
const originalPhotonAttachments = new Map<Attachment, string>();
|
|
if (
|
|
input.endpoint.provider === "microsoft-teams" &&
|
|
input.unavailableReferenceCount
|
|
) {
|
|
// A channel/group reference is not a download capability. Still tell
|
|
// the exact current-turn consumer that those bytes are unavailable,
|
|
// including when there are no ingestible attachments at all.
|
|
omit("download_unavailable", input.unavailableReferenceCount);
|
|
}
|
|
if (
|
|
["github", "imessage-photon"].includes(input.endpoint.provider) &&
|
|
input.attachmentLimitOmissions
|
|
) {
|
|
omit("attachment_limit", input.attachmentLimitOmissions);
|
|
}
|
|
if (input.attachments.length > boundedAttachments.length) {
|
|
omit(
|
|
"attachment_limit",
|
|
input.attachments.length - boundedAttachments.length,
|
|
);
|
|
}
|
|
if (!options.storage) {
|
|
if (boundedAttachments.length)
|
|
omit("storage_unavailable", boundedAttachments.length);
|
|
return { storedIds: [], omissionReasons };
|
|
}
|
|
const existingByFingerprint = new Map<string, string[]>();
|
|
const existingAttachments = await db
|
|
.select({
|
|
id: issueAttachments.id,
|
|
byteSize: assets.byteSize,
|
|
contentType: assets.contentType,
|
|
originalFilename: assets.originalFilename,
|
|
sha256: assets.sha256,
|
|
})
|
|
.from(issueAttachments)
|
|
.innerJoin(
|
|
assets,
|
|
and(
|
|
eq(assets.companyId, issueAttachments.companyId),
|
|
eq(assets.id, issueAttachments.assetId),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(issueAttachments.companyId, input.endpoint.companyId),
|
|
eq(issueAttachments.issueId, input.issueId),
|
|
eq(issueAttachments.issueCommentId, input.issueCommentId),
|
|
),
|
|
);
|
|
for (const existing of existingAttachments) {
|
|
const fingerprint = JSON.stringify([
|
|
existing.sha256,
|
|
existing.byteSize,
|
|
existing.contentType,
|
|
existing.originalFilename,
|
|
]);
|
|
const ids = existingByFingerprint.get(fingerprint) ?? [];
|
|
ids.push(existing.id);
|
|
existingByFingerprint.set(fingerprint, ids);
|
|
}
|
|
const storedIds: string[] = [];
|
|
const withCurrentAttachmentAuthorization = async <T>(
|
|
work: (tx: DbTransaction) => Promise<T>,
|
|
enforceUnchangedSource = false,
|
|
telegramMedia?: Attachment,
|
|
): Promise<T> => {
|
|
for (let attempt = 0; ; attempt++) {
|
|
try {
|
|
return await db.transaction(async (tx) => {
|
|
const action = await tx
|
|
.select()
|
|
.from(chatActions)
|
|
.where(
|
|
and(
|
|
eq(chatActions.companyId, input.endpoint.companyId),
|
|
eq(chatActions.endpointId, input.endpoint.id),
|
|
eq(chatActions.deliveryId, input.deliveryId),
|
|
eq(chatActions.kind, "inbound_wakeup"),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0]);
|
|
if (
|
|
!action ||
|
|
!["preparing", "issued", "processing", "processed"].includes(
|
|
action.status,
|
|
) ||
|
|
action.payload.issueId !== input.issueId ||
|
|
action.payload.commentId !== input.issueCommentId
|
|
)
|
|
throw forbidden(
|
|
"The attachment's current chat admission is unavailable",
|
|
{ code: "chat_action_authorization_changed" },
|
|
);
|
|
const current = await authorizeInboundWakeup(tx, action);
|
|
// Delivery settlement/replay can update these receipts without
|
|
// taking the issue lock. Lock in delivery -> action order only
|
|
// after policy authorization; NOWAIT breaks cross-worker lock
|
|
// inversions and the outer loop retries the whole transaction.
|
|
const lockedDelivery = await tx
|
|
.select()
|
|
.from(chatDeliveries)
|
|
.where(
|
|
and(
|
|
eq(chatDeliveries.companyId, input.endpoint.companyId),
|
|
eq(chatDeliveries.id, input.deliveryId),
|
|
),
|
|
)
|
|
.for("update", { noWait: true })
|
|
.limit(1)
|
|
.then((rows) => rows[0]);
|
|
const lockedAction = await tx
|
|
.select()
|
|
.from(chatActions)
|
|
.where(
|
|
and(
|
|
eq(chatActions.companyId, input.endpoint.companyId),
|
|
eq(chatActions.id, action.id),
|
|
),
|
|
)
|
|
.for("update", { noWait: true })
|
|
.limit(1)
|
|
.then((rows) => rows[0]);
|
|
const scopeKeys = [
|
|
"version",
|
|
"issueId",
|
|
"commentId",
|
|
"agentId",
|
|
"sessionGeneration",
|
|
"requestedByActorType",
|
|
"requestedByActorId",
|
|
] as const;
|
|
if (
|
|
current.delivery.state !== "processing" ||
|
|
!lockedDelivery ||
|
|
!lockedAction ||
|
|
lockedDelivery.state !== "processing" ||
|
|
lockedDelivery.endpointId !== action.endpointId ||
|
|
lockedDelivery.conversationId !== action.conversationId ||
|
|
lockedDelivery.principalId !== action.principalId ||
|
|
lockedDelivery.attempts !== current.delivery.attempts ||
|
|
JSON.stringify(lifecycleRuntimeFence(lockedDelivery)) !==
|
|
JSON.stringify(lifecycleRuntimeFence(current.delivery)) ||
|
|
lockedAction.endpointId !== action.endpointId ||
|
|
lockedAction.deliveryId !== action.deliveryId ||
|
|
lockedAction.conversationId !== action.conversationId ||
|
|
lockedAction.principalId !== action.principalId ||
|
|
lockedAction.kind !== "inbound_wakeup" ||
|
|
!["preparing", "issued", "processing", "processed"].includes(
|
|
lockedAction.status,
|
|
) ||
|
|
scopeKeys.some(
|
|
(key) => lockedAction.payload[key] !== action.payload[key],
|
|
)
|
|
)
|
|
throw forbidden(
|
|
"The attachment's current chat admission is unavailable",
|
|
{ code: "chat_action_authorization_changed" },
|
|
);
|
|
if (enforceUnchangedSource) {
|
|
if (telegramMedia) {
|
|
const normalized = lockedDelivery.normalizedEvent as {
|
|
principal?: { externalId?: string };
|
|
message?: {
|
|
providerMessageId?: string;
|
|
attachments?: Array<{ recovery?: unknown }>;
|
|
};
|
|
};
|
|
const fence = lifecycleRuntimeFence(lockedDelivery);
|
|
const descriptor =
|
|
input.endpointRuntime.attachmentRecoveryDescriptor(
|
|
telegramMedia,
|
|
{
|
|
threadId: current.conversation.externalThreadId,
|
|
messageId: normalized.message?.providerMessageId ?? "",
|
|
principalExternalId: normalized.principal?.externalId,
|
|
runtimeGeneration: fence?.generation,
|
|
credentialFingerprint: fence?.credentialFingerprint,
|
|
},
|
|
);
|
|
if (
|
|
descriptor?.locator.kind !== "telegram_media" ||
|
|
!normalized.message?.attachments?.some((item) => {
|
|
const retained = item.recovery as {
|
|
version?: unknown;
|
|
provider?: unknown;
|
|
locator?: Record<string, unknown>;
|
|
attachment?: Record<string, unknown>;
|
|
} | null;
|
|
return (
|
|
retained?.version === 1 &&
|
|
retained.provider === "telegram" &&
|
|
Object.entries(descriptor.locator).every(
|
|
([key, value]) => retained.locator?.[key] === value,
|
|
) &&
|
|
Object.entries(descriptor.attachment).every(
|
|
([key, value]) => retained.attachment?.[key] === value,
|
|
)
|
|
);
|
|
})
|
|
)
|
|
throw forbidden(
|
|
"The media's admitted source is unavailable",
|
|
{ code: "chat_action_authorization_changed" },
|
|
);
|
|
}
|
|
const [source] = await tx
|
|
.select({ id: issueComments.id })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, input.endpoint.companyId),
|
|
eq(issueComments.issueId, input.issueId),
|
|
eq(issueComments.id, input.issueCommentId),
|
|
isNull(issueComments.deletedAt),
|
|
sql`${issueComments.updatedAt} = ${issueComments.createdAt}`,
|
|
),
|
|
)
|
|
.for("share", { noWait: true });
|
|
// Lifecycle admission precedes its conversation drain. Pending
|
|
// source changes already invalidate this image download, even
|
|
// when the append-only original comment is still untouched.
|
|
const [lifecycle] = await tx
|
|
.select({ id: chatDeliveries.id })
|
|
.from(chatDeliveries)
|
|
.where(
|
|
and(
|
|
eq(chatDeliveries.companyId, input.endpoint.companyId),
|
|
eq(chatDeliveries.endpointId, input.endpoint.id),
|
|
or(
|
|
isNull(chatDeliveries.conversationId),
|
|
eq(
|
|
chatDeliveries.conversationId,
|
|
current.conversation.id,
|
|
),
|
|
),
|
|
ne(chatDeliveries.state, "filtered"),
|
|
inArray(chatDeliveries.eventKind, [
|
|
"message_updated",
|
|
"message_deleted",
|
|
"message_restored",
|
|
]),
|
|
sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(lifecycleRuntimeFence(lockedDelivery))}::jsonb`,
|
|
sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${lockedDelivery.providerEventId}`,
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!source || lifecycle)
|
|
throw forbidden(
|
|
"The image's admitted source changed before attachment registration",
|
|
{
|
|
code: "chat_action_authorization_changed",
|
|
},
|
|
);
|
|
}
|
|
return await work(tx);
|
|
});
|
|
} catch (error) {
|
|
if (!isExternalChatWaitAuthorizationContention(error) || attempt >= 4)
|
|
throw error;
|
|
// Retry the complete short transaction; no network/storage work or
|
|
// backoff occurs while governance rows are locked.
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, 20 * (attempt + 1)),
|
|
);
|
|
}
|
|
}
|
|
};
|
|
// One shared deadline bounds the sequential batch; an expired batch never
|
|
// starts another GitHub request. Other providers retain their own policy.
|
|
const githubBatchSignal =
|
|
input.endpoint.provider === "github"
|
|
? AbortSignal.timeout(GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS)
|
|
: undefined;
|
|
// This new image-only lane shares one budget across token acquisition and
|
|
// downloads. DB/storage commit semantics remain explicit and unchanged.
|
|
const teamsInlineBatchSignal =
|
|
input.endpoint.provider === "microsoft-teams"
|
|
? AbortSignal.timeout(10_000)
|
|
: undefined;
|
|
for (let attachment of boundedAttachments) {
|
|
const teamsInlineImage =
|
|
input.endpoint.provider === "microsoft-teams" &&
|
|
input.endpointRuntime.attachmentRecoveryDescriptor(attachment)?.locator
|
|
.kind === "teams_inline_image";
|
|
const telegramMedia =
|
|
input.endpoint.provider === "telegram" &&
|
|
hasTelegramMediaProvenance(attachment);
|
|
const sourceBoundMedia =
|
|
teamsInlineImage ||
|
|
telegramMedia ||
|
|
input.endpoint.provider === "imessage-photon";
|
|
const requireCurrentAttachmentAuthorization =
|
|
input.endpoint.provider === "github" || sourceBoundMedia;
|
|
try {
|
|
if (teamsInlineImage && teamsInlineBatchSignal?.aborted) {
|
|
omit("download_unavailable");
|
|
continue;
|
|
}
|
|
// GitHub's anonymized upload URLs carry no trustworthy MIME metadata.
|
|
// Resolve public bytes only here, after durable comment admission, and
|
|
// then apply the same storage/type policy as every native attachment.
|
|
if (input.endpoint.provider === "github") {
|
|
await withCurrentAttachmentAuthorization(async () => {});
|
|
attachment = await prepareGitHubPublicAttachment(
|
|
attachment,
|
|
githubBatchSignal,
|
|
async (request, signal) => {
|
|
await withCurrentAttachmentAuthorization(async () => {});
|
|
return (
|
|
(await input.endpointRuntime.resolveGitHubAttachmentComment?.(
|
|
request,
|
|
signal,
|
|
)) ?? null
|
|
);
|
|
},
|
|
);
|
|
}
|
|
if (
|
|
requireCurrentAttachmentAuthorization &&
|
|
input.endpoint.provider !== "github"
|
|
)
|
|
await withCurrentAttachmentAuthorization(
|
|
async () => {},
|
|
sourceBoundMedia,
|
|
telegramMedia ? attachment : undefined,
|
|
);
|
|
if (
|
|
attachment.size !== undefined &&
|
|
attachment.size > MAX_ATTACHMENT_BYTES
|
|
) {
|
|
omit("declared_too_large");
|
|
continue;
|
|
}
|
|
if (!attachment.fetchData) {
|
|
omit("download_unavailable");
|
|
continue;
|
|
}
|
|
const originalFilename = sanitizeFilename(attachment.name);
|
|
let contentType = normalizeUploadAttachmentContentType({
|
|
contentType: normalizeContentType(
|
|
attachment.mimeType ?? "application/octet-stream",
|
|
),
|
|
originalFilename,
|
|
isAllowedContentType,
|
|
});
|
|
// Reject a provider-declared type before allocating the downloaded
|
|
// payload. The actual byte length is checked again after the adapter's
|
|
// bounded fetch contract resolves.
|
|
// A filename may infer an Office MIME for ordinary documents, but it
|
|
// must not bypass byte identification for native media without MIME.
|
|
const identifyTelegram =
|
|
telegramMedia &&
|
|
telegramMediaNeedsIdentification(attachment) &&
|
|
normalizeContentType(
|
|
attachment.mimeType ?? "application/octet-stream",
|
|
) === "application/octet-stream";
|
|
if (!isAllowedContentType(contentType) && !identifyTelegram) {
|
|
omit("unsupported_type");
|
|
continue;
|
|
}
|
|
const fetched = teamsInlineImage
|
|
? await input.endpointRuntime.fetchTeamsInlineImage(
|
|
attachment,
|
|
teamsInlineBatchSignal!,
|
|
)
|
|
: await attachment.fetchData();
|
|
const body = Buffer.isBuffer(fetched) ? fetched : Buffer.from(fetched);
|
|
if (body.length === 0) {
|
|
omit("empty_download");
|
|
continue;
|
|
}
|
|
if (body.length > MAX_ATTACHMENT_BYTES) {
|
|
omit("downloaded_too_large");
|
|
continue;
|
|
}
|
|
if (identifyTelegram) {
|
|
contentType =
|
|
identifyTelegramMedia(attachment, body) ??
|
|
"application/octet-stream";
|
|
if (!isAllowedContentType(contentType)) {
|
|
omit("unsupported_type");
|
|
continue;
|
|
}
|
|
}
|
|
const photonSourceHash = createHash("sha256")
|
|
.update(body)
|
|
.digest("hex");
|
|
if (input.endpoint.provider === "imessage-photon") {
|
|
await validatePhotonImage(body, contentType);
|
|
const companion = takePhotonCompanion(body);
|
|
if (companion?.unavailable) omit("companion_unavailable");
|
|
else if (companion) {
|
|
const video: Attachment = { type: "video", name: companion.fileName, mimeType: companion.mimeType, size: companion.data.length, fetchData: async () => companion.data };
|
|
photonDerivatives.set(video, { source: attachment, sourceHash: photonSourceHash, kind: "live_photo_video" });
|
|
boundedAttachments.push(video);
|
|
}
|
|
if (
|
|
HEIF_CONTENT_TYPES.has(contentType) &&
|
|
isAllowedContentType("image/jpeg")
|
|
) {
|
|
try {
|
|
const preview = await photonHeifPreview(body);
|
|
const derivative: Attachment = {
|
|
type: "image",
|
|
name: `${originalFilename} (JPEG preview).jpg`,
|
|
mimeType: "image/jpeg",
|
|
size: preview.length,
|
|
fetchData: async () => preview,
|
|
};
|
|
photonDerivatives.set(derivative, { source: attachment, sourceHash: photonSourceHash, kind: "heif_jpeg_preview" });
|
|
boundedAttachments.push(derivative);
|
|
} catch {
|
|
omit("preview_unavailable");
|
|
}
|
|
}
|
|
}
|
|
const fingerprint = JSON.stringify([
|
|
photonSourceHash,
|
|
body.length,
|
|
contentType,
|
|
originalFilename,
|
|
]);
|
|
const existingIds = existingByFingerprint.get(fingerprint);
|
|
const existingId = existingIds?.shift();
|
|
if (requireCurrentAttachmentAuthorization) {
|
|
// Revoke during either API resolution or the anonymous byte download
|
|
// must prevent storage and attachment registration for that input.
|
|
await withCurrentAttachmentAuthorization(
|
|
async () => {},
|
|
sourceBoundMedia,
|
|
telegramMedia ? attachment : undefined,
|
|
);
|
|
}
|
|
const registerPhotonProvenance = async (attachmentId: string) => {
|
|
if (input.endpoint.provider !== "imessage-photon") return;
|
|
const relation = photonDerivatives.get(attachment);
|
|
if (!relation) {
|
|
originalPhotonAttachments.set(attachment, attachmentId);
|
|
return;
|
|
}
|
|
const originalAttachmentId =
|
|
originalPhotonAttachments.get(relation.source);
|
|
if (!originalAttachmentId)
|
|
throw new Error("Photon related attachment source is missing");
|
|
await db
|
|
.insert(chatActions)
|
|
.values({
|
|
companyId: input.endpoint.companyId,
|
|
endpointId: input.endpoint.id,
|
|
deliveryId: input.deliveryId,
|
|
kind: relation.kind === "heif_jpeg_preview" ? "attachment_derivative" : "attachment_companion",
|
|
providerActionId: `photon-${relation.kind}:${input.deliveryId}:${originalAttachmentId}`,
|
|
payload: {
|
|
originalAttachmentId,
|
|
derivativeAttachmentId: attachmentId,
|
|
originalSha256: relation.sourceHash,
|
|
derivativeSha256: photonSourceHash,
|
|
kind: relation.kind,
|
|
},
|
|
status: "processed",
|
|
})
|
|
.onConflictDoNothing();
|
|
};
|
|
if (existingId) {
|
|
storedIds.push(existingId);
|
|
await registerPhotonProvenance(existingId);
|
|
continue;
|
|
}
|
|
const stored = await options.storage.putFile({
|
|
companyId: input.endpoint.companyId,
|
|
namespace: `issues/${input.issueId}`,
|
|
originalFilename,
|
|
contentType,
|
|
body,
|
|
});
|
|
const registration = {
|
|
issueId: input.issueId,
|
|
issueCommentId: input.issueCommentId,
|
|
provider: stored.provider,
|
|
objectKey: stored.objectKey,
|
|
contentType: stored.contentType,
|
|
byteSize: stored.byteSize,
|
|
sha256: stored.sha256,
|
|
originalFilename: stored.originalFilename,
|
|
createdByUserId: input.actorUserId,
|
|
};
|
|
let row;
|
|
try {
|
|
row = requireCurrentAttachmentAuthorization
|
|
? await withCurrentAttachmentAuthorization(
|
|
(tx) =>
|
|
issueService(tx as unknown as Db).createAttachment(
|
|
registration,
|
|
),
|
|
sourceBoundMedia,
|
|
telegramMedia ? attachment : undefined,
|
|
)
|
|
: await issuesSvc.createAttachment(registration);
|
|
} catch (error) {
|
|
// An explicit authorization denial happens before registration. Do
|
|
// not delete on ambiguous database errors which may have committed.
|
|
if (
|
|
requireCurrentAttachmentAuthorization &&
|
|
isExternalActionAuthorizationChange(error)
|
|
)
|
|
await options.storage
|
|
.deleteObject(input.endpoint.companyId, stored.objectKey)
|
|
.catch(() => undefined);
|
|
throw error;
|
|
}
|
|
storedIds.push(row.id);
|
|
await registerPhotonProvenance(row.id);
|
|
} catch (error) {
|
|
if (isExternalActionAuthorizationChange(error)) throw error;
|
|
if (
|
|
error instanceof PhotonError &&
|
|
["attachment_not_ready", "network", "quota", "credentials"].includes(
|
|
error.code,
|
|
)
|
|
)
|
|
throw error;
|
|
// Use the closed current-input omission vocabulary consumed by native
|
|
// prompts; provider-specific diagnostics remain redacted log codes.
|
|
omit(
|
|
error instanceof GitHubAttachmentUnavailableError
|
|
? "download_unavailable"
|
|
: "processing_failed",
|
|
);
|
|
// A malformed or unavailable provider attachment must not strand the
|
|
// durable text delivery. The rejected file is intentionally omitted;
|
|
// the delivery remains auditable through its normalized attachment
|
|
// metadata without persisting provider credentials or download URLs.
|
|
logger.warn(
|
|
{
|
|
endpointId: input.endpoint.id,
|
|
issueId: input.issueId,
|
|
...(error instanceof GitHubAttachmentUnavailableError
|
|
? {
|
|
deliveryId: input.deliveryId,
|
|
attachmentDiagnosticCode: error.code,
|
|
}
|
|
: {}),
|
|
attachmentName: sanitizeFilename(attachment.name),
|
|
error: redactError(error),
|
|
},
|
|
"external chat attachment was rejected",
|
|
);
|
|
}
|
|
}
|
|
return { storedIds, omissionReasons };
|
|
}
|
|
|
|
function attachmentOmissionDetail(result: {
|
|
omissionReasons: Record<string, number>;
|
|
}): string | null {
|
|
const entries = Object.entries(result.omissionReasons).filter(
|
|
([, count]) => count > 0,
|
|
);
|
|
const omitted = entries.reduce((total, [, count]) => total + count, 0);
|
|
if (!omitted) return null;
|
|
const reasons = entries
|
|
.map(([reason, count]) => `${reason.replaceAll("_", " ")}: ${count}`)
|
|
.join(", ");
|
|
return `${omitted} external attachment${omitted === 1 ? " was" : "s were"} omitted (${reasons})`;
|
|
}
|
|
return { ingestAttachments, attachmentOmissionDetail };
|
|
}
|