#!/usr/bin/env python3 """Split a Telegram HTML export into hash-bound, compiler-sized source artifacts.""" from __future__ import annotations import argparse import hashlib import json import re from dataclasses import dataclass from datetime import datetime from html.parser import HTMLParser from pathlib import Path, PurePosixPath from typing import Any from urllib.parse import unquote, urlsplit SCHEMA = "livingip.telegramExportCorpus.v1" PARSER_VERSION = "1" DEFAULT_MAX_SEGMENT_CHARS = 100_000 ATTACHMENT_PREFIXES = frozenset( {"animations", "files", "photos", "round_video_messages", "stickers", "video_files", "voice_messages"} ) VOID_TAGS = frozenset({"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}) SECRET_VALUE_RE = re.compile( r"(?i)(\b(?:[a-z][a-z0-9_]*(?:_?key|_?token|_?secret|_?password|_?passcode|_?credential)|" r"x-api-key|authorization)\b\s*[:=]\s*(?:bearer\s+)?)([\"']?)([a-z0-9_./+=:-]{8,})([\"']?)" ) class ExportError(RuntimeError): """Raised when an export cannot be converted without losing provenance.""" @dataclass(frozen=True) class TelegramMessage: message_id: str timestamp: str sender: str reply_to: str | None text: str links: tuple[str, ...] def sha256_bytes(value: bytes) -> str: return hashlib.sha256(value).hexdigest() def canonical_json_bytes(value: Any) -> bytes: return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") def _clean_text(chunks: list[str]) -> str: text = "".join(chunks).replace("\r\n", "\n").replace("\r", "\n") lines = [line.rstrip() for line in text.splitlines()] return "\n".join(lines).strip() def _timestamp(value: str) -> str: try: return datetime.strptime(value, "%d.%m.%Y %H:%M:%S UTC%z").isoformat() except ValueError as exc: raise ExportError(f"unsupported Telegram timestamp: {value}") from exc class TelegramHtmlParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.stack: list[tuple[str, frozenset[str]]] = [] self.current: dict[str, Any] | None = None self.message_depth: int | None = None self.last_sender: str | None = None self.messages: list[TelegramMessage] = [] self.title_chunks: list[str] = [] def _inside(self, class_name: str) -> bool: return any(class_name in classes for _tag, classes in self.stack) def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: values = {key: value or "" for key, value in attrs} classes = frozenset(values.get("class", "").split()) if tag in VOID_TAGS: if self.current is not None and tag == "br" and self._inside("text"): self.current["text"].append("\n") return if tag == "div" and {"message", "default"}.issubset(classes): if self.current is not None: raise ExportError("nested Telegram message containers are unsupported") raw_id = values.get("id", "") if not re.fullmatch(r"message\d+", raw_id): raise ExportError(f"invalid Telegram message id: {raw_id or ''}") self.current = { "message_id": raw_id.removeprefix("message"), "timestamp": None, "sender": [], "reply_to": None, "text": [], "links": [], } self.message_depth = len(self.stack) self.stack.append((tag, classes)) if self.current is None: return if tag == "div" and "date" in classes and values.get("title"): self.current["timestamp"] = _timestamp(values["title"]) if tag == "a" and values.get("href"): href = values["href"].strip() if self._inside("reply_to"): match = re.search(r"(?:go_to_message|message)(\d+)", href) if match: self.current["reply_to"] = match.group(1) elif href and href not in self.current["links"]: self.current["links"].append(href) def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: self.handle_starttag(tag, attrs) if tag not in VOID_TAGS: self.handle_endtag(tag) def handle_data(self, data: str) -> None: if self.current is None: if self._inside("page_header") and self._inside("text"): self.title_chunks.append(data) return if self._inside("from_name") and not self._inside("forwarded"): self.current["sender"].append(data) elif self._inside("text"): self.current["text"].append(data) def handle_endtag(self, tag: str) -> None: if not self.stack: return closing_message = tag == "div" and self.message_depth == len(self.stack) - 1 self.stack.pop() if closing_message: self._finish_message() def _finish_message(self) -> None: assert self.current is not None sender = _clean_text(self.current["sender"]) or self.last_sender timestamp = self.current["timestamp"] if not sender: raise ExportError(f"message {self.current['message_id']} has no sender and no joined-message predecessor") if not timestamp: raise ExportError(f"message {self.current['message_id']} has no timestamp") self.last_sender = sender self.messages.append( TelegramMessage( message_id=self.current["message_id"], timestamp=timestamp, sender=sender, reply_to=self.current["reply_to"], text=_clean_text(self.current["text"]), links=tuple(self.current["links"]), ) ) self.current = None self.message_depth = None @property def chat_title(self) -> str: return _clean_text(self.title_chunks) def parse_export(path: Path) -> tuple[str, list[TelegramMessage]]: try: raw = path.read_text(encoding="utf-8", errors="strict") except (OSError, UnicodeDecodeError) as exc: raise ExportError(f"could not read strict UTF-8 Telegram export: {exc}") from exc parser = TelegramHtmlParser() try: parser.feed(raw) parser.close() except (ValueError, AssertionError) as exc: raise ExportError(f"could not parse Telegram export: {exc}") from exc if parser.current is not None: raise ExportError("Telegram export ended inside a message") if not parser.messages: raise ExportError("Telegram export contains no default messages") return parser.chat_title, parser.messages def redact_secret_values(value: str) -> tuple[str, int]: count = 0 def replacement(match: re.Match[str]) -> str: nonlocal count count += 1 opening = match.group(2) closing = match.group(4) if match.group(4) == opening else opening return f"{match.group(1)}{opening}[REDACTED_SECRET]{closing}" return SECRET_VALUE_RE.sub(replacement, value), count def _redact_messages(messages: list[TelegramMessage]) -> tuple[list[TelegramMessage], list[str], int]: redacted = [] affected_ids = [] value_count = 0 for message in messages: text, text_count = redact_secret_values(message.text) links = [] link_count = 0 for link in message.links: safe_link, count = redact_secret_values(link) links.append(safe_link) link_count += count count = text_count + link_count if count: affected_ids.append(message.message_id) value_count += count redacted.append( TelegramMessage( message_id=message.message_id, timestamp=message.timestamp, sender=message.sender, reply_to=message.reply_to, text=text, links=tuple(links), ) ) return redacted, affected_ids, value_count def _format_message(message: TelegramMessage, source_sha256: str) -> str: metadata = { "id": message.message_id, "reply_to": message.reply_to, "sender": message.sender, "source_ref": f"telegram-export:sha256:{source_sha256}#message={message.message_id}", "timestamp": message.timestamp, } parts = [json.dumps(metadata, ensure_ascii=False, separators=(",", ":"), sort_keys=True)] parts.append(message.text or "[attachment-only message]") if message.links: parts.append("Links:\n" + "\n".join(f"- {link}" for link in message.links)) return "\n".join(parts).strip() + "\n" def _split_messages(messages: list[TelegramMessage], source_sha256: str, max_chars: int) -> list[tuple[str, ...]]: if max_chars < 1_000 or max_chars > 110_000: raise ExportError("max segment characters must be between 1000 and 110000") segments: list[list[str]] = [] current: list[str] = [] current_chars = 0 for message in messages: rendered = _format_message(message, source_sha256) if len(rendered) > max_chars: raise ExportError(f"message {message.message_id} exceeds the segment character limit") separator_chars = 1 if current else 0 if current and current_chars + separator_chars + len(rendered) > max_chars: segments.append(current) current = [] current_chars = 0 separator_chars = 0 current.append(rendered) current_chars += separator_chars + len(rendered) if current: segments.append(current) return [tuple(segment) for segment in segments] def _attachment_relative_path(href: str) -> PurePosixPath | None: parsed = urlsplit(href) if parsed.scheme or parsed.netloc or not parsed.path: return None relative = PurePosixPath(unquote(parsed.path)) if not relative.parts or relative.parts[0] not in ATTACHMENT_PREFIXES: return None if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts): raise ExportError(f"unsafe Telegram attachment path: {href}") return relative def _attachment_inventory(export_root: Path, messages: list[TelegramMessage]) -> list[dict[str, Any]]: references: dict[str, set[str]] = {} for message in messages: for href in message.links: relative = _attachment_relative_path(href) if relative is not None: references.setdefault(relative.as_posix(), set()).add(message.message_id) inventory = [] root = export_root.resolve() for relative_text, message_ids in sorted(references.items()): path = export_root / relative_text try: resolved = path.resolve(strict=True) except OSError as exc: raise ExportError(f"referenced Telegram attachment is missing: {relative_text}") from exc if root not in resolved.parents or not resolved.is_file() or resolved.is_symlink(): raise ExportError(f"unsafe Telegram attachment target: {relative_text}") data = resolved.read_bytes() inventory.append( { "bytes": len(data), "message_ids": sorted(message_ids, key=int), "path": relative_text, "sha256": sha256_bytes(data), } ) return inventory def prepare(export_path: Path, output_dir: Path, max_segment_chars: int) -> dict[str, Any]: if not export_path.is_file() or export_path.is_symlink(): raise ExportError("export path must be a regular, non-symlink file") source = export_path.read_bytes() source_sha256 = sha256_bytes(source) title, messages = parse_export(export_path) messages, secret_message_ids, secret_value_count = _redact_messages(messages) segments = _split_messages(messages, source_sha256, max_segment_chars) attachments = _attachment_inventory(export_path.parent, messages) if output_dir.exists(): raise ExportError("output directory already exists; choose a new private path") output_dir.mkdir(mode=0o700, parents=True) output_dir.chmod(0o700) segment_rows = [] offset = 0 for index, rendered_messages in enumerate(segments, start=1): filename = f"segment-{index:04d}.txt" text = "\n".join(rendered_messages) data = text.encode("utf-8") path = output_dir / filename path.write_bytes(data) path.chmod(0o600) count = len(rendered_messages) selected = messages[offset : offset + count] offset += count segment_rows.append( { "bytes": len(data), "characters": len(text), "first_message_id": selected[0].message_id, "last_message_id": selected[-1].message_id, "message_count": count, "path": filename, "sha256": sha256_bytes(data), "source_identity": f"telegram-export:sha256:{source_sha256}#segment={index:04d}", } ) corpus_binding = { "attachments": attachments, "segments": segment_rows, "source_html_sha256": source_sha256, } manifest = { "schema": SCHEMA, "parser_version": PARSER_VERSION, "source": { "bytes": len(source), "chat_title": title, "filename": export_path.name, "sha256": source_sha256, }, "bounds": {"max_segment_characters": max_segment_chars}, "counts": { "attachments": len(attachments), "messages": len(messages), "participants": len({message.sender for message in messages}), "segments": len(segment_rows), }, "participants": sorted({message.sender for message in messages}), "time_range": {"first": messages[0].timestamp, "last": messages[-1].timestamp}, "secret_redaction": { "message_count": len(secret_message_ids), "message_ids": secret_message_ids, "value_count": secret_value_count, }, "segments": segment_rows, "attachments": attachments, "corpus_sha256": sha256_bytes(canonical_json_bytes(corpus_binding)), "safety": { "database_write_performed": False, "model_call_performed": False, "message_text_in_manifest": False, "output_files_private": True, "secret_values_removed_before_segments": True, }, } manifest_path = output_dir / "corpus-manifest.json" manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") manifest_path.chmod(0o600) return manifest def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--export", required=True, type=Path) parser.add_argument("--output-dir", required=True, type=Path) parser.add_argument("--max-segment-chars", default=DEFAULT_MAX_SEGMENT_CHARS, type=int) return parser.parse_args() def main() -> int: args = parse_args() try: manifest = prepare(args.export, args.output_dir, args.max_segment_chars) except ExportError as exc: print(json.dumps({"schema": SCHEMA, "status": "error", "error": str(exc)}, sort_keys=True)) return 2 print( json.dumps( { "schema": SCHEMA, "status": "prepared", "messages": manifest["counts"]["messages"], "segments": manifest["counts"]["segments"], "corpus_sha256": manifest["corpus_sha256"], }, sort_keys=True, ) ) return 0 if __name__ == "__main__": raise SystemExit(main())