T-HUB/apps/archive-bot/src/events.ts
glass beadian d58db63fe6 Add group cloud: files index api, web UI, archive sync bot
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.
2026-10-11 08:21:11 +00:00

109 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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] };
}