scripts/chat_export_to_transcript.py — Chat Export to Transcript
From version 0.1.1 of Chat Export to Transcript, shown publicly by its creator. 31.8 KiB · SHA-256 86568bdc9a9d6030ec2909f3e6367e49e6d4167ba7b3d97b101ac875a44aeb52.
#!/usr/bin/env python3
# By Zero Width Lab (zerowidthlab.com)
"""Convert official chat exports into uniform JSONL and readable transcripts.
By Zero Width Lab (zerowidthlab.com)
"""
from __future__ import annotations
import argparse
import errno
import csv
import hashlib
import io
import json
import os
import re
import stat
import sys
import unicodedata
import zipfile
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
__author__ = "Zero Width Lab"
VERSION = "0.1.1"
SIGNATURE = "By Zero Width Lab (zerowidthlab.com)"
MAX_INPUT_BYTES = 32 * 1024 * 1024
MAX_ZIP_MEMBERS = 100
CONTINUATION_PREFIX = " | "
class CliError(Exception):
pass
class Parser(argparse.ArgumentParser):
def error(self, message: str) -> None:
raise CliError(message)
def display_text(value: Any) -> str:
parts = []
for character in str(value):
if character in {"\u2028", "\u2029"} or unicodedata.category(character).startswith("C"):
number = ord(character)
parts.append(f"\\u{number:04x}" if number <= 65535 else f"\\U{number:08x}")
else:
parts.append(character)
return "".join(parts)
def require_platform() -> None:
if (sys.platform not in {"linux", "darwin"} or os.name != "posix" or not hasattr(os, "O_DIRECTORY") or
not hasattr(os, "O_NOFOLLOW") or os.open not in os.supports_dir_fd or
os.mkdir not in os.supports_dir_fd or os.stat not in os.supports_dir_fd or
os.stat not in os.supports_follow_symlinks or not hasattr(os, "O_NONBLOCK")):
raise CliError("requires POSIX directory descriptors and no-follow APIs (macOS/Linux); Windows is unsupported")
def validate_path(path: Path) -> None:
raw = str(path)
if "\0" in raw or ".." in path.parts:
raise CliError("path traversal and NUL are unsupported")
def open_directory(path: Path) -> int:
validate_path(path)
absolute = Path(os.path.abspath(path))
descriptor = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
for part in absolute.parts[1:]:
try:
next_descriptor = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=descriptor)
except OSError as exc:
if exc.errno in {errno.ENOTDIR, errno.ELOOP}:
raise CliError("a path component is a symlink; use the physical path (for example /private/tmp)") from exc
raise
os.close(descriptor)
descriptor = next_descriptor
return descriptor
except BaseException:
os.close(descriptor)
raise
def path_kind(path: Path, directory: bool = False) -> bool:
parent = open_directory(path.parent)
try:
try:
metadata = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
except FileNotFoundError:
return False
if stat.S_ISLNK(metadata.st_mode):
raise CliError(f"symlink input is unsupported: {display_text(path)}")
return stat.S_ISDIR(metadata.st_mode) if directory else stat.S_ISREG(metadata.st_mode)
finally:
os.close(parent)
def directory_children(path: Path, directories: bool = False) -> list[Path]:
descriptor = open_directory(path)
try:
children = []
for name in sorted(os.listdir(descriptor)):
metadata = os.stat(name, dir_fd=descriptor, follow_symlinks=False)
if stat.S_ISLNK(metadata.st_mode):
raise CliError(f"symlink in export is unsupported: {display_text(path / name)}")
if not directories or stat.S_ISDIR(metadata.st_mode):
children.append(path / name)
return children
finally:
os.close(descriptor)
def reject_export_symlinks(root: Path) -> None:
"""Check export directory metadata without opening unrelated file contents."""
stack = [root]
while stack:
directory = stack.pop()
for child in directory_children(directory):
if path_kind(child, directory=True):
stack.append(child)
def identity(descriptor: int) -> list[int]:
value = os.fstat(descriptor)
return [value.st_dev, value.st_ino]
def read_file(path: Path) -> bytes:
validate_path(path)
try:
parent = open_directory(path.parent)
try:
descriptor = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
finally:
os.close(parent)
with os.fdopen(descriptor, "rb") as handle:
metadata = os.fstat(handle.fileno())
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_INPUT_BYTES:
raise CliError(f"input must be a regular file of at most {MAX_INPUT_BYTES} bytes: {display_text(path)}")
data = handle.read(MAX_INPUT_BYTES + 1)
if len(data) > MAX_INPUT_BYTES:
raise CliError(f"input exceeds {MAX_INPUT_BYTES} bytes: {display_text(path)}")
return data
except OSError as exc:
raise CliError(f"cannot read input: {display_text(path)}: {display_text(exc)}") from exc
def decoded(data: bytes, label: str) -> str:
try:
return data.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise CliError(f"invalid UTF-8 in {display_text(label)}") from exc
def json_file(path: Path) -> Any:
try:
return json.loads(decoded(read_file(path), str(path)))
except (json.JSONDecodeError, RecursionError) as exc:
raise CliError(f"invalid JSON in {display_text(path)}: {display_text(exc)}") from exc
def valid_timestamp(value: Any) -> str | None:
if not isinstance(value, str) or not value.strip():
return None
if not re.fullmatch(
r"(?:\d{4}-\d{2}-\d{2}|\d{8})(?:[T ](?:\d{2}:\d{2}(?::\d{2})?|\d{4}(?:\d{2})?)(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?",
value.strip(), flags=re.ASCII):
return None
match = re.fullmatch(
r"(\d{4})-?(\d{2})-?(\d{2})(?:[T ](\d{2}):?(\d{2})(?::?(\d{2}))?(?:[.,](\d+))?(Z|[+-]\d{2}:?\d{2})?)?",
value.strip(), flags=re.ASCII)
if not match:
return None
year, month, day, hour, minute, second, fraction, offset = match.groups()
candidate = f"{year}-{month}-{day}"
if hour is not None:
candidate += f"T{hour}:{minute}:{second or '00'}"
if fraction:
candidate += "." + (fraction + "000000")[:6]
if offset:
candidate += "+00:00" if offset == "Z" else offset[:3] + ":" + offset[-2:]
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return None
return parsed.isoformat(timespec="seconds")
def validate_record_fields(record: dict[str, Any], label: str, fields: tuple[str, ...]) -> None:
for field in fields:
if field in record and record[field] is not None and not isinstance(record[field], (str, int)):
raise CliError(f"{label}: {field} must be text or an integer")
def validate_text_fields(record: dict[str, Any], label: str, fields: tuple[str, ...]) -> None:
for field in fields:
if field in record and record[field] is not None and not isinstance(record[field], str):
raise CliError(f"{label}: {field} must be text")
def message(platform: str, conversation: str, message_id: Any, timestamp: str,
author: str, text: str, attachments: list[str] | None = None,
reply_to: Any = None, system: bool = False) -> dict[str, Any]:
return {"platform": platform, "conversation": conversation,
"message_id": str(message_id) if message_id is not None else None,
"timestamp": timestamp, "author": author, "text": text,
"attachments": attachments or [],
"reply_to": str(reply_to) if reply_to is not None else None,
"system": system}
def wa_timestamp(date_text: str, time_text: str, day_first: bool) -> str | None:
date_text = date_text.replace(".", "/").replace("-", "/")
fields = date_text.split("/")
if len(fields) != 3:
return None
try:
if len(fields[0]) == 4:
year, month, day = (int(item) for item in fields)
else:
first, second, year = (int(item) for item in fields)
if year < 100:
year += 2000
month, day = (second, first) if day_first or first > 12 else (first, second)
normalized = time_text.strip().replace("\u202f", " ").replace("\u00a0", " ")
parsed_time = None
for pattern in ("%I:%M:%S %p", "%I:%M %p", "%H:%M:%S", "%H:%M"):
try:
parsed_time = datetime.strptime(normalized.upper(), pattern).time()
break
except ValueError:
continue
if parsed_time is None:
return None
return datetime(year, month, day, parsed_time.hour, parsed_time.minute,
parsed_time.second).isoformat(timespec="seconds")
except ValueError:
return None
def parse_whatsapp(data: bytes, conversation: str, day_first: bool) -> tuple[list[dict[str, Any]], list[str]]:
text = decoded(data, conversation)
records: list[dict[str, Any]] = []
skipped: list[str] = []
current: dict[str, Any] | None = None
pattern = (r"^(?:\[)?(\d{1,4}[/.\-]\d{1,2}[/.\-]\d{1,4}),?\s+"
r"(\d{1,2}:\d{2}(?::\d{2})?(?:\s*[AaPp]\.?[Mm]\.?)?)"
r"(?:\])?\s*(?:-\s*)?(.*)$")
for number, line in enumerate(text.splitlines(), 1):
match = re.match(pattern, line)
stamp = wa_timestamp(match.group(1), match.group(2).replace(".", ""), day_first) if match else None
if stamp:
payload = match.group(3).lstrip()
author_match = re.match(r"^([^:]{1,200}):\s?(.*)$", payload)
author = author_match.group(1) if author_match else "System"
body = author_match.group(2) if author_match else payload
attachments = []
if "<Media omitted>" in body:
attachments.append("Media omitted")
attachment_match = re.search(r"([^\\/\s<>]+\.[A-Za-z0-9]{1,8})\s*\(file attached\)", body)
if attachment_match:
attachments.append(attachment_match.group(1))
current = message("whatsapp", conversation, None, stamp, author, body,
attachments, system=author_match is None)
records.append(current)
elif match:
skipped.append(f"{conversation}: line {number}: invalid date or time")
current = None
elif current is not None:
current["text"] += "\n" + line
elif line.strip():
skipped.append(f"{conversation}: line {number}: unparseable")
return records, skipped
def parse_whatsapp_path(path: Path, day_first: bool) -> tuple[list[dict[str, Any]], list[str]]:
if path.suffix.lower() == ".txt":
return parse_whatsapp(read_file(path), path.stem, day_first)
try:
with zipfile.ZipFile(io.BytesIO(read_file(path))) as archive:
infos = archive.infolist()
if len(infos) > MAX_ZIP_MEMBERS:
raise CliError("WhatsApp ZIP has too many members")
for item in infos:
member = Path(item.filename)
validate_path(member)
if "%" in item.filename or "\\" in item.filename:
raise CliError("encoded or backslash ZIP member is unsupported")
if member.is_absolute() or re.match(r"^[A-Za-z]:", item.filename):
raise CliError("absolute WhatsApp ZIP member is unsupported")
candidates = [item for item in infos if item.filename.lower().endswith(".txt") and not item.is_dir()]
if len(candidates) != 1:
raise CliError("WhatsApp ZIP must contain exactly one chat TXT file")
info = candidates[0]
if info.file_size > MAX_INPUT_BYTES:
raise CliError("WhatsApp TXT in ZIP exceeds size limit")
with archive.open(info) as handle:
data = handle.read(MAX_INPUT_BYTES + 1)
if len(data) > MAX_INPUT_BYTES:
raise CliError("WhatsApp TXT in ZIP exceeds size limit")
return parse_whatsapp(data, Path(info.filename).stem, day_first)
except (OSError, zipfile.BadZipFile, RuntimeError, NotImplementedError) as exc:
raise CliError(f"cannot read WhatsApp ZIP: {display_text(exc)}") from exc
def parse_discord(root: Path) -> tuple[list[dict[str, Any]], list[str]]:
messages: list[dict[str, Any]] = []
skipped: list[str] = []
seen_channels: dict[str, str] = {}
channels_root = root / "messages"
if not path_kind(channels_root, directory=True):
raise CliError("Discord export needs messages/<channel>/messages.json or messages.csv")
for channel_dir in directory_children(channels_root, directories=True):
if not path_kind(channel_dir, directory=True):
continue
json_path, csv_path = channel_dir / "messages.json", channel_dir / "messages.csv"
if not path_kind(json_path) and not path_kind(csv_path):
continue
channel_meta = channel_dir / "channel.json"
conversation = channel_dir.name
channel_name = None
if not path_kind(channel_meta):
skipped.append(f"{channel_dir.name}: channel.json missing")
else:
metadata = json_file(channel_meta)
if isinstance(metadata, dict):
validate_record_fields(metadata, "channel.json", ("id",))
validate_text_fields(metadata, "channel.json", ("name",))
conversation = str(metadata.get("id") or conversation)
channel_name = metadata.get("name")
else:
raise CliError(f"{channel_dir.name}: channel.json expected object")
if conversation in seen_channels:
raise CliError(f"duplicate Discord channel ID {conversation}: folders {seen_channels[conversation]} and {channel_dir.name}")
seen_channels[conversation] = channel_dir.name
if path_kind(json_path):
payload = json_file(json_path)
if not isinstance(payload, list):
skipped.append(f"{channel_dir.name}/messages.json: expected array")
continue
else:
try:
payload = list(csv.DictReader(io.StringIO(decoded(read_file(csv_path), str(csv_path)))))
except csv.Error as exc:
skipped.append(f"{channel_dir.name}/messages.csv: {display_text(exc)}")
continue
for index, record in enumerate(payload, 1):
if not isinstance(record, dict):
skipped.append(f"{channel_dir.name}: record {index}: expected object")
continue
validate_record_fields(record, f"{channel_dir.name}: record {index}", ("ID", "id", "reply_to"))
if "system" in record and not isinstance(record["system"], bool):
raise CliError("Discord system field must be boolean")
stamp = valid_timestamp(record.get("Timestamp") or record.get("timestamp"))
content = record.get("Contents", record.get("content", ""))
if stamp is None or not isinstance(content, str):
skipped.append(f"{channel_dir.name}: record {index}: invalid timestamp or content")
continue
attachments_raw = record.get("Attachments", record.get("attachments", []))
if isinstance(attachments_raw, str):
attachments = [item.strip() for item in attachments_raw.split(",") if item.strip()]
elif isinstance(attachments_raw, list):
attachments = []
for attachment in attachments_raw:
value = attachment.get("filename") or attachment.get("url") if isinstance(attachment, dict) else attachment
if not isinstance(value, str):
raise CliError(f"{channel_dir.name}: record {index}: invalid attachment")
attachments.append(value)
else:
raise CliError(f"{channel_dir.name}: record {index}: attachments expected string or array")
author = record.get("Author", record.get("author", "Unknown"))
if isinstance(author, dict):
validate_text_fields(author, "Discord author", ("name", "username"))
author = author.get("name") or author.get("username") or "Unknown"
if not isinstance(author, str):
raise CliError("Discord author must be a string or name object")
messages.append(message("discord", conversation, record.get("ID", record.get("id")),
stamp, str(author), content, attachments,
record.get("reply_to"), bool(record.get("system", False))))
messages[-1]["channel_name"] = channel_name
if not messages and not skipped:
raise CliError("no Discord message files found")
return messages, skipped
def telegram_text(value: Any) -> str | None:
if isinstance(value, str):
return value
if isinstance(value, list):
parts = []
for item in value:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict) and isinstance(item.get("text"), str):
parts.append(item["text"])
else:
return None
return "".join(parts)
return None
def parse_telegram(path: Path) -> tuple[list[dict[str, Any]], list[str]]:
if path.name != "result.json":
raise CliError("Telegram Desktop JSON export must be result.json; HTML is unsupported")
payload = json_file(path)
if not isinstance(payload, dict) or not isinstance(payload.get("messages"), list):
raise CliError("Telegram result.json needs a messages array")
validate_text_fields(payload, "result.json", ("name",))
conversation = str(payload.get("name") or "Telegram chat")
messages = []
skipped = []
for index, item in enumerate(payload["messages"], 1):
if not isinstance(item, dict):
skipped.append(f"result.json: record {index}: expected object")
continue
validate_record_fields(item, f"result.json: record {index}", ("id", "reply_to_message_id"))
validate_text_fields(item, f"result.json: record {index}", ("from", "actor", "type", "file", "photo"))
stamp = valid_timestamp(item.get("date"))
body = telegram_text(item.get("text", ""))
if stamp is None or body is None:
skipped.append(f"result.json: record {index}: invalid date or text")
continue
attachments = [str(item[key]) for key in ("file", "photo") if isinstance(item.get(key), str)]
messages.append(message("telegram", conversation, item.get("id"), stamp,
str(item.get("from") or item.get("actor") or "System"), body,
attachments, item.get("reply_to_message_id"),
item.get("type") != "message"))
return messages, skipped
def slack_timestamp(value: Any) -> str | None:
try:
number = float(value)
return datetime.fromtimestamp(number, timezone.utc).isoformat(timespec="seconds")
except (TypeError, ValueError, OverflowError, OSError):
return None
def parse_slack(root: Path) -> tuple[list[dict[str, Any]], list[str]]:
user_path = root / "users.json"
if not path_kind(user_path):
raise CliError("Slack export needs users.json")
users_raw = json_file(user_path)
if not isinstance(users_raw, list):
raise CliError("Slack users.json must be an array")
users = {}
for item in users_raw:
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
raise CliError("Slack users.json entry needs a string id")
if "profile" in item and not isinstance(item["profile"], dict):
raise CliError("Slack user profile must be an object")
if isinstance(item, dict) and isinstance(item.get("id"), str):
profile = item.get("profile") if isinstance(item.get("profile"), dict) else {}
for values, fields in ((profile, ("display_name", "real_name")), (item, ("name",))):
for field in fields:
if field in values and values[field] is not None and not isinstance(values[field], str):
raise CliError(f"Slack user {field} must be a string")
users[item["id"]] = str(profile.get("display_name") or profile.get("real_name") or item.get("name") or item["id"])
channels = directory_children(root, directories=True)
legacy = root / "channels"
if legacy in channels and not any(file.suffix == ".json" for file in directory_children(legacy)):
channels.remove(legacy)
channels.extend(directory_children(legacy, directories=True))
if len({channel.name for channel in channels}) != len(channels):
raise CliError("duplicate Slack channel folders across root and channels/ layouts")
messages = []
skipped = []
for channel in channels:
if not path_kind(channel, directory=True):
continue
for file in directory_children(channel):
if file.suffix != ".json":
continue
payload = json_file(file)
if not isinstance(payload, list):
skipped.append(f"{channel.name}/{file.name}: expected array")
continue
for index, item in enumerate(payload, 1):
if not isinstance(item, dict):
skipped.append(f"{channel.name}/{file.name}: record {index}: expected object")
continue
validate_text_fields(item, f"{channel.name}/{file.name}: record {index}", ("user", "username", "client_msg_id", "thread_ts", "subtype"))
stamp = slack_timestamp(item.get("ts"))
body = item.get("text", "")
if stamp is None or not isinstance(body, str):
skipped.append(f"{channel.name}/{file.name}: record {index}: invalid ts or text")
continue
attachments_raw = item.get("files", [])
if not isinstance(attachments_raw, list) or any(not isinstance(value, dict) for value in attachments_raw):
raise CliError(f"{channel.name}/{file.name}: record {index}: files must be an array of objects")
attachments = []
for value in attachments_raw:
label = value.get("name") or value.get("id")
if not isinstance(label, str):
raise CliError(f"{channel.name}/{file.name}: record {index}: file needs a string name or id")
attachments.append(label)
user = str(item.get("user") or item.get("username") or "System")
messages.append(message("slack", channel.name, item.get("client_msg_id") or item.get("ts"),
stamp, users.get(user, user), body, attachments,
item.get("thread_ts") if item.get("thread_ts") != item.get("ts") else None,
item.get("subtype") not in (None, "")))
if not messages and not skipped:
raise CliError("no Slack channel files found")
return messages, skipped
def detect_format(path: Path) -> str:
if path_kind(path):
if path.name == "result.json":
return "telegram"
if path.suffix.lower() in {".txt", ".zip"}:
return "whatsapp"
elif path_kind(path, directory=True):
if path_kind(path / "users.json"):
return "slack"
if path_kind(path / "messages", directory=True):
return "discord"
raise CliError("cannot detect official export format; pass --format")
def transcript(messages: list[dict[str, Any]]) -> str:
lines = []
if messages and messages[0]["platform"] == "discord":
first = messages[0]
label = first.get("channel_name") or first["conversation"]
lines.append(f"Discord channel: {display_text(label)} (ID: {display_text(first['conversation'])})")
for item in messages:
body_lines = item["text"].splitlines() or [""]
body_lines = [display_text(line) for line in body_lines]
extras = "".join(f" [attachment: {display_text(name)}]" for name in item["attachments"])
body_lines[-1] += extras
lines.append(f"{display_text(item['timestamp'])} {display_text(item['author'])}: {body_lines[0]}")
lines.extend(CONTINUATION_PREFIX + line for line in body_lines[1:])
return "\n".join(lines) + ("\n" if lines else "")
def render_files(messages: list[dict[str, Any]]) -> dict[str, bytes]:
result = {"messages.jsonl": ("".join(json.dumps(item, ensure_ascii=True, sort_keys=True) + "\n" for item in messages)).encode("utf-8")}
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for item in messages:
grouped[item["conversation"]].append(item)
used = set()
for conversation in sorted(grouped):
base = re.sub(r"[^A-Za-z0-9._-]+", "-", conversation).strip(".-_")[:60] or "conversation"
suffix = hashlib.sha256(conversation.encode("utf-8")).hexdigest()[:8]
name = f"transcripts/{base}-{suffix}.txt"
if name in used:
raise CliError("transcript filename collision")
used.add(name)
result[name] = transcript(grouped[conversation]).encode("utf-8")
return result
def output_plan(path: Path) -> dict[str, Any]:
validate_path(path)
parent = open_directory(path.parent)
try:
parent_identity = identity(parent)
try:
descriptor = os.open(path.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
except FileNotFoundError:
destination_identity = None
else:
try:
if os.listdir(descriptor):
raise CliError("output directory must be new or empty")
destination_identity = identity(descriptor)
finally:
os.close(descriptor)
return {"destination": str(path.resolve(strict=False)),
"parent_identity": parent_identity, "directory_identity": destination_identity,
"create_destination": destination_identity is None}
finally:
os.close(parent)
def plan_digest(files: dict[str, bytes], destination: Path, approved: dict[str, Any] | None = None) -> str:
plan = dict(approved) if approved is not None else output_plan(destination)
plan["create_directories"] = (["."] if plan["create_destination"] else []) + (["transcripts"] if any(name.startswith("transcripts/") for name in files) else [])
plan["files"] = [{"path": name, "size": len(files[name]),
"sha256": hashlib.sha256(files[name]).hexdigest()} for name in sorted(files)]
return hashlib.sha256(json.dumps(plan, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def prepare_output(path: Path) -> None:
output_plan(path)
def write_files(path: Path, files: dict[str, bytes], approved: dict[str, Any]) -> None:
parent = open_directory(path.parent)
destination = None
transcripts_dir = None
try:
if identity(parent) != approved["parent_identity"] or str(path.resolve(strict=False)) != approved["destination"]:
raise CliError("output parent changed after approval")
if approved["create_destination"]:
os.mkdir(path.name, mode=0o700, dir_fd=parent)
destination = os.open(path.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
if approved["directory_identity"] is not None and identity(destination) != approved["directory_identity"]:
raise CliError("output directory changed after approval")
if os.listdir(destination):
raise CliError("output directory must be new or empty")
if any(name.startswith("transcripts/") for name in files):
os.mkdir("transcripts", mode=0o700, dir_fd=destination)
transcripts_dir = os.open("transcripts", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=destination)
for name in sorted(files):
target_dir = transcripts_dir if name.startswith("transcripts/") else destination
descriptor = os.open(Path(name).name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600, dir_fd=target_dir)
with os.fdopen(descriptor, "wb") as handle:
handle.write(files[name])
except OSError as exc:
raise CliError(f"cannot create output: {display_text(exc)}; any partial output is retained") from exc
finally:
if transcripts_dir is not None:
os.close(transcripts_dir)
if destination is not None:
os.close(destination)
os.close(parent)
def run(argv: list[str] | None = None) -> int:
parser = Parser(description="Convert official chat exports to transcripts", epilog=SIGNATURE)
parser.add_argument("--version", action="version", version=VERSION)
sub = parser.add_subparsers(dest="command", required=True)
convert = sub.add_parser("convert", help="convert an official export", epilog=SIGNATURE)
convert.add_argument("input", type=Path)
convert.add_argument("--out", type=Path, required=True)
convert.add_argument("--format", choices=("whatsapp", "discord", "telegram", "slack"))
convert.add_argument("--day-first", action="store_true", help="interpret ambiguous WhatsApp dates as day/month/year")
convert.add_argument("--dry-run", action="store_true")
convert.add_argument("--approve", help="SHA-256 digest from the matching dry run")
convert.add_argument("--allow-partial", action="store_true")
args = parser.parse_args(argv)
require_platform()
validate_path(args.input)
validate_path(args.out)
source = args.input
format_name = args.format or detect_format(source)
if format_name in {"discord", "slack"}:
reject_export_symlinks(source)
if format_name == "whatsapp":
if not path_kind(source):
raise CliError("WhatsApp input must be a .txt or .zip file")
messages, skipped = parse_whatsapp_path(source, args.day_first)
elif format_name == "telegram":
messages, skipped = parse_telegram(source)
elif format_name == "discord":
messages, skipped = parse_discord(source)
else:
messages, skipped = parse_slack(source)
if not messages and not skipped:
raise CliError("no valid messages found")
files = render_files(messages)
prepare_output(args.out)
approved = output_plan(args.out)
digest = plan_digest(files, args.out, approved)
print(f"status: {'incomplete' if skipped else 'complete'}")
print(f"messages: {len(messages)}")
print(f"skipped: {len(skipped)}")
for item in skipped:
print(f" skipped: {display_text(item)}")
for name in sorted(files):
print(f" file: {display_text(args.out / name)} ({len(files[name])} bytes)")
if approved["create_destination"]:
print(f" create directory: {display_text(args.out)}")
if any(name.startswith("transcripts/") for name in files):
print(f" create directory: {display_text(args.out / 'transcripts')}")
print(f"approval digest: {digest}")
if not args.dry_run:
if args.approve != digest:
raise CliError("live create requires --approve with the matching dry-run digest")
if skipped and not args.allow_partial:
raise CliError("incomplete export; use --allow-partial to write valid records")
write_files(args.out, files, approved)
return 2 if skipped and not args.allow_partial else 0
def main() -> int:
try:
return run()
except CliError as exc:
print(f"error: {display_text(exc)}", file=sys.stderr)
return 2
except (OSError, ValueError, csv.Error) as exc:
print(f"error: {display_text(exc)}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
License: MIT (read it).
MIT
MIT LicenseCopyright (c) 2026 Zero Width Lab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.