import { canArchiveAttachment, type MatrixAttachmentCandidate } from "@tactical-media-broadcast-hub/core"; import { toGroupFileMapping, type RoomMapping } from "./config.js"; /** Client-server API event shape (only the fields the bot reads). */ export interface MatrixEvent { type: string; event_id: string; sender: string; origin_server_ts?: number; state_key?: string; /** Room v1–v10 redactions carry the target here; v11+ moved it into content. */ redacts?: string; content: Record; unsigned?: { redacted_because?: unknown; [key: string]: unknown }; } export type RoomEncryption = MatrixAttachmentCandidate["roomEncryption"]; export const ATTACHMENT_MSGTYPES = ["m.file", "m.image", "m.audio", "m.video"] as const; export interface AttachmentWork { kind: "attachment"; event: MatrixEvent; candidate: MatrixAttachmentCandidate; /** Sender-supplied display name; only ever used after normalisation. */ displayName: string; } export type Classified = | AttachmentWork | { kind: "redaction"; event: MatrixEvent; redacts: string } | { kind: "skip"; event: MatrixEvent; reason: string }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** Runtime guard for raw JSON from /sync or /messages. */ export function isMatrixEvent(value: unknown): value is MatrixEvent { return isRecord(value) && typeof value.type === "string" && typeof value.event_id === "string" && typeof value.sender === "string" && isRecord(value.content); } export function redactionTarget(event: MatrixEvent): string | null { const target = typeof event.content.redacts === "string" ? event.content.redacts : event.redacts; return typeof target === "string" && target.startsWith("$") ? target : null; } function isEdit(content: Record): boolean { const relates = content["m.relates_to"]; return (isRecord(relates) && relates.rel_type === "m.replace") || "m.new_content" in content; } /** * Decide what (if anything) to do with a timeline event in a mapped room. * Every exclusion from the processing contract lives here so it is unit-testable; * the final eligibility gate is the shared `canArchiveAttachment`. */ export function classifyEvent( event: MatrixEvent, mapping: RoomMapping, roomEncryption: RoomEncryption, botUserId: string, ): Classified { if (event.sender === botUserId) return { kind: "skip", event, reason: "own-event" }; if (event.type === "m.room.redaction") { const redacts = redactionTarget(event); return redacts === null ? { kind: "skip", event, reason: "redaction-without-target" } : { kind: "redaction", event, redacts }; } if (event.type === "m.room.encrypted") return { kind: "skip", event, reason: "encrypted-event" }; if (event.type !== "m.room.message") return { kind: "skip", event, reason: "not-a-message" }; if (event.unsigned?.redacted_because !== undefined) return { kind: "skip", event, reason: "redacted" }; const { content } = event; const msgtype = typeof content.msgtype === "string" ? content.msgtype : ""; if (!(ATTACHMENT_MSGTYPES as readonly string[]).includes(msgtype)) { return { kind: "skip", event, reason: "not-an-attachment" }; } if (isEdit(content)) return { kind: "skip", event, reason: "edit" }; if (content.file !== undefined) return { kind: "skip", event, reason: "encrypted-file" }; if (roomEncryption !== "unencrypted") return { kind: "skip", event, reason: `room-${roomEncryption}` }; const candidate: MatrixAttachmentCandidate = { roomId: mapping.roomId, eventId: event.event_id, senderId: event.sender, eventType: event.type, msgtype, roomEncryption, encryptedFile: content.file !== undefined, redacted: false, mxcUri: typeof content.url === "string" ? content.url : "", }; if (!canArchiveAttachment(toGroupFileMapping(mapping), candidate)) { return { kind: "skip", event, reason: "ineligible" }; } const filename = typeof content.filename === "string" && content.filename.trim() !== "" ? content.filename : null; const body = typeof content.body === "string" ? content.body : ""; return { kind: "attachment", event, candidate, displayName: filename ?? body }; } /** Split a validated `mxc://server/mediaId` into its parts. */ export function parseMxc(mxcUri: string): { serverName: string; mediaId: string } { const match = /^mxc:\/\/([^/\s]+)\/([^/\s?#]+)$/.exec(mxcUri); if (match === null || match[1] === undefined || match[2] === undefined) throw new Error("invalid mxc URI"); return { serverName: match[1], mediaId: match[2] }; }