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.
This commit is contained in:
parent
eb1b60f67d
commit
d58db63fe6
58 changed files with 5543 additions and 4 deletions
109
apps/archive-bot/src/events.ts
Normal file
109
apps/archive-bot/src/events.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
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] };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue