The Telegram-like group file contract (packages/core shared-files.ts) becomes a working subsystem, built in three parallel tracks: API (NestJS): migration 029 group_files (chat_media_archive metadata-only index with partial replay-unique + RLS, group_file_shares with sha256 token persistence, archive_sync_state/outbox durable-claim seam incl. a unique claim_key index), group-files module (list/search with cursor pagination, direct upload ≤20 MiB streamed+hashed to oCIS WebDAV, download with ACL + retention rechecks, share links with expiry, retention modes incl. legalHold) — RBAC group-membership authz + audit chain events. Web: Files section per group — group picker, drag-drop upload (20 MiB cap), search + MIME/origin filters, cursor pagination, download via authenticated blob fetch, share dialog with expiry + shown-once token, admin retention dialog; bilingual EN/ES (wiki i18n pattern); nav sidebar/palette/bottom-nav wiring; MIME classes aligned to the DB CHECK (document/other). Archive bot (apps/archive-bot): plain sync-loop Matrix bot mirroring attachments from explicitly-mapped unencrypted archive rooms — raw /sync with durable since-cursor, outbox-claimed ingestion (hash-verified stream, no temp files), magic-byte MIME sniffing, safe-name normalization, WebDAV upload, indexed + replied with link, redaction→unavailable; health endpoint; Dockerfile. Tests 74/74, typecheck clean across api/web/bot.
109 lines
4.6 KiB
TypeScript
109 lines
4.6 KiB
TypeScript
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<string, unknown>;
|
||
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<string, unknown> {
|
||
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<string, unknown>): 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] };
|
||
}
|