teleo-infrastructure/scripts/prepare_telegram_export_corpus.py
twentyOne2x 1472c8513a
Some checks are pending
CI / lint-and-test (push) Waiting to run
Prepare Telegram exports for deterministic KB reconstruction (#168)
2026-07-15 21:38:27 +02:00

415 lines
16 KiB
Python

#!/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 '<missing>'}")
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())