gchat-archive 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of gchat-archive might be problematic. Click here for more details.

@@ -0,0 +1,3 @@
1
+ """Local, searchable archive of Google Chat history."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env python3
2
+ """Registry of Google accounts, each with its own isolated archive.
3
+
4
+ One account = one OAuth token + one archive directory (raw JSONL, SQLite,
5
+ attachments). Nothing is shared, so a broken or removed account can never damage
6
+ another one, and re-syncing one account cannot touch the others' history.
7
+
8
+ Layout:
9
+ <data>/accounts.json
10
+ <data>/accounts/<slug>/ raw/ chats.sqlite attachments/ ...
11
+ <data>/tokens/<slug>.json tokens for accounts added here
12
+
13
+ <data> is ``~/.gchat-archive`` unless ``GCHAT_ARCHIVE_HOME`` says otherwise.
14
+ An older single-account layout (archive files directly under <data>) is
15
+ migrated into accounts/<slug>/ on first use, so existing archives keep working.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import shutil
23
+ from pathlib import Path
24
+
25
+ from . import paths
26
+
27
+ ROOT = paths.data_home()
28
+ REGISTRY = ROOT / "accounts.json"
29
+ ACCOUNTS_DIR = ROOT / "accounts"
30
+ TOKENS_DIR = ROOT / "tokens"
31
+ # No implicit token: every install signs in its own account through OAuth.
32
+ DEFAULT_TOKEN = TOKENS_DIR / "default.json"
33
+
34
+ # Files that belong to an account's archive.
35
+ ARCHIVE_FILES = ("raw", "attachments", "attachments.jsonl", "attachments_text.jsonl")
36
+ ARCHIVE_GLOBS = ("chats.sqlite*", "state.json", "archive_manage.json", "sync.log", "final_sync.log")
37
+
38
+
39
+ def slug_for(email: str) -> str:
40
+ """Directory-safe, stable id for an account email."""
41
+ cleaned = re.sub(r"[^a-z0-9]+", "_", (email or "default").lower()).strip("_")
42
+ return cleaned or "default"
43
+
44
+
45
+ def load() -> dict:
46
+ if REGISTRY.exists():
47
+ try:
48
+ data = json.loads(REGISTRY.read_text())
49
+ if isinstance(data, dict) and isinstance(data.get("accounts"), list):
50
+ return data
51
+ except json.JSONDecodeError:
52
+ pass
53
+ return {"active": None, "accounts": []}
54
+
55
+
56
+ def save(data: dict) -> None:
57
+ ROOT.mkdir(parents=True, exist_ok=True)
58
+ tmp = REGISTRY.with_suffix(".tmp")
59
+ tmp.write_text(json.dumps(data, indent=2))
60
+ os.replace(tmp, REGISTRY)
61
+
62
+
63
+ def account_root(slug: str) -> Path:
64
+ return ACCOUNTS_DIR / slug
65
+
66
+
67
+ def token_path(slug: str) -> Path:
68
+ for entry in load()["accounts"]:
69
+ if entry.get("slug") == slug:
70
+ return Path(entry.get("token") or DEFAULT_TOKEN)
71
+ return DEFAULT_TOKEN
72
+
73
+
74
+ def _legacy_files_present() -> bool:
75
+ return (ROOT / "raw").exists() or (ROOT / "chats.sqlite").exists()
76
+
77
+
78
+ def _detect_email() -> str:
79
+ """Email of the existing archive, from state.json, else the token file."""
80
+ state = ROOT / "state.json"
81
+ if state.exists():
82
+ try:
83
+ me = json.loads(state.read_text()).get("me")
84
+ if me:
85
+ return me
86
+ except json.JSONDecodeError:
87
+ pass
88
+ if DEFAULT_TOKEN.exists():
89
+ try:
90
+ return json.loads(DEFAULT_TOKEN.read_text()).get("account") or "default"
91
+ except json.JSONDecodeError:
92
+ pass
93
+ return "default"
94
+
95
+
96
+ def migrate_legacy() -> dict:
97
+ """Move a single-account archive into accounts/<slug>/ exactly once."""
98
+ data = load()
99
+ if data["accounts"] or not _legacy_files_present():
100
+ return data
101
+
102
+ email = _detect_email()
103
+ slug = slug_for(email)
104
+ dest = account_root(slug)
105
+ dest.mkdir(parents=True, exist_ok=True)
106
+
107
+ for name in ARCHIVE_FILES:
108
+ src = ROOT / name
109
+ if src.exists():
110
+ shutil.move(str(src), str(dest / name))
111
+ for pattern in ARCHIVE_GLOBS:
112
+ for src in ROOT.glob(pattern):
113
+ if src.is_file():
114
+ shutil.move(str(src), str(dest / src.name))
115
+
116
+ data["accounts"].append(
117
+ {
118
+ "slug": slug,
119
+ "email": email,
120
+ "label": email,
121
+ "token": str(DEFAULT_TOKEN),
122
+ "added_at": None,
123
+ }
124
+ )
125
+ _rewrite_manifest_paths(dest)
126
+ data["active"] = slug
127
+ save(data)
128
+ return data
129
+
130
+
131
+ def _rewrite_manifest_paths(dest: Path) -> None:
132
+ """Point attachment records at the account directory after a move.
133
+
134
+ Silently skipping this makes every attachment resolve to a path that no
135
+ longer exists, so the reader reports "not archived locally" for all of them.
136
+ """
137
+ manifest = dest / "attachments.jsonl"
138
+ if not manifest.exists():
139
+ return
140
+ fixed = []
141
+ for line in manifest.read_text().splitlines():
142
+ if not line.strip():
143
+ continue
144
+ try:
145
+ rec = json.loads(line)
146
+ except json.JSONDecodeError:
147
+ continue
148
+ old = Path(rec.get("path") or "")
149
+ if old and not old.exists():
150
+ candidate = dest / "attachments" / old.name
151
+ if candidate.exists():
152
+ rec["path"] = str(candidate)
153
+ fixed.append(json.dumps(rec))
154
+ manifest.write_text("\n".join(fixed) + "\n")
155
+
156
+
157
+ def repair_manifests() -> int:
158
+ """Fix stale attachment paths in every registered account. Idempotent."""
159
+ touched = 0
160
+ for entry in ensure()["accounts"]:
161
+ dest = account_root(entry["slug"])
162
+ manifest = dest / "attachments.jsonl"
163
+ if not manifest.exists():
164
+ continue
165
+ before = manifest.read_text()
166
+ _rewrite_manifest_paths(dest)
167
+ if manifest.read_text() != before:
168
+ touched += 1
169
+ return touched
170
+
171
+
172
+ def ensure() -> dict:
173
+ """Load the registry, migrating the legacy layout if this is a first run."""
174
+ data = load()
175
+ if not data["accounts"]:
176
+ data = migrate_legacy()
177
+ if not data["accounts"] and not (ROOT / "accounts").exists():
178
+ # Nothing to migrate, nothing registered, no archive on disk: this is
179
+ # a fresh install. Do not invent an account — `gchat-archive login`
180
+ # creates one when the user actually signs in.
181
+ return {"active": "", "accounts": []}
182
+ if not data["accounts"]:
183
+ email = _detect_email()
184
+ slug = slug_for(email)
185
+ account_root(slug).mkdir(parents=True, exist_ok=True)
186
+ data = {
187
+ "active": slug,
188
+ "accounts": [
189
+ {
190
+ "slug": slug,
191
+ "email": email,
192
+ "label": email,
193
+ "token": str(DEFAULT_TOKEN),
194
+ "added_at": None,
195
+ }
196
+ ],
197
+ }
198
+ save(data)
199
+ if data["accounts"] and (
200
+ not data.get("active") or not any(a["slug"] == data["active"] for a in data["accounts"])
201
+ ):
202
+ data["active"] = data["accounts"][0]["slug"]
203
+ save(data)
204
+ return data
205
+
206
+
207
+ def active_slug() -> str:
208
+ override = os.environ.get("HERMES_ARCHIVE_ACCOUNT")
209
+ if override:
210
+ return slug_for(override)
211
+ return ensure()["active"]
212
+
213
+
214
+ def active_root() -> Path:
215
+ root = account_root(active_slug())
216
+ root.mkdir(parents=True, exist_ok=True)
217
+ return root
218
+
219
+
220
+ def active_token() -> Path:
221
+ return token_path(active_slug())
222
+
223
+
224
+ def add_account(email: str, token_file: Path, label: str | None = None) -> dict:
225
+ """Register an account, moving its token somewhere stable."""
226
+ data = ensure()
227
+ slug = slug_for(email)
228
+ TOKENS_DIR.mkdir(parents=True, exist_ok=True)
229
+ dest = TOKENS_DIR / ("%s.json" % slug)
230
+ if Path(token_file).resolve() != dest.resolve():
231
+ shutil.copy2(token_file, dest)
232
+ # A token carries a refresh token plus the client secret: owner-only.
233
+ os.chmod(dest, 0o600)
234
+
235
+ for entry in data["accounts"]:
236
+ if entry["slug"] == slug:
237
+ entry.update({"email": email, "token": str(dest), "label": label or email})
238
+ save(data)
239
+ return data
240
+ data["accounts"].append(
241
+ {
242
+ "slug": slug,
243
+ "email": email,
244
+ "label": label or email,
245
+ "token": str(dest),
246
+ "sync": True,
247
+ "added_at": __import__("datetime").datetime.now().astimezone().isoformat(timespec="seconds"),
248
+ }
249
+ )
250
+ save(data)
251
+ return data
252
+
253
+
254
+ def remove_account(slug: str, delete_archive: bool = True) -> dict:
255
+ """Drop an account and (optionally) its archive. Never removes the last one."""
256
+ data = ensure()
257
+ keep = [a for a in data["accounts"] if a["slug"] != slug]
258
+ if not keep:
259
+ raise ValueError("cannot remove the only account")
260
+ token = next((a.get("token") for a in data["accounts"] if a["slug"] == slug), None)
261
+ data["accounts"] = keep
262
+ if data.get("active") == slug:
263
+ data["active"] = keep[0]["slug"]
264
+ save(data)
265
+
266
+ if token:
267
+ try:
268
+ path = Path(token)
269
+ if path.exists() and TOKENS_DIR in path.resolve().parents:
270
+ path.unlink()
271
+ except OSError:
272
+ pass
273
+ if delete_archive:
274
+ target = account_root(slug)
275
+ try:
276
+ # Compare resolved paths on both sides: on macOS the home path can
277
+ # resolve through /System/Volumes/Data, and an unresolved check
278
+ # silently fails, leaving a multi-hundred-MB directory orphaned.
279
+ if target.exists() and ACCOUNTS_DIR.resolve() in target.resolve().parents:
280
+ shutil.rmtree(target)
281
+ except OSError:
282
+ pass
283
+ return data
284
+
285
+
286
+ def account_enabled(entry: dict) -> bool:
287
+ """Per-account sync switch (absent means enabled, for older registries)."""
288
+ return bool(entry.get("sync", True))
289
+
290
+
291
+ def images_enabled(slug: str | None = None) -> bool:
292
+ """Whether this account also downloads image attachments (default: off)."""
293
+ target = slug or active_slug()
294
+ for entry in ensure()["accounts"]:
295
+ if entry["slug"] == target:
296
+ return bool(entry.get("images", False))
297
+ return False
298
+
299
+
300
+ def set_images(slug: str, enabled: bool) -> dict:
301
+ data = ensure()
302
+ for entry in data["accounts"]:
303
+ if entry["slug"] == slug:
304
+ entry["images"] = bool(enabled)
305
+ save(data)
306
+ return data
307
+ raise ValueError("unknown account")
308
+
309
+
310
+ def set_sync(slug: str, enabled: bool) -> dict:
311
+ data = ensure()
312
+ for entry in data["accounts"]:
313
+ if entry["slug"] == slug:
314
+ entry["sync"] = bool(enabled)
315
+ save(data)
316
+ return data
317
+ raise ValueError("unknown account")
318
+
319
+
320
+ def set_active(slug: str) -> dict:
321
+ data = ensure()
322
+ if not any(a["slug"] == slug for a in data["accounts"]):
323
+ raise ValueError("unknown account")
324
+ data["active"] = slug
325
+ save(data)
326
+ return data
gchat_archive/api.py ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ """Google Chat REST access for the offline archive.
3
+
4
+ Deliberately not a thin wrapper around a generic helper: per-space 403/404 must
5
+ not abort a sync, and 429/5xx must back off with jitter rather than exit.
6
+
7
+ Known API quirk (verified 2026-10-23): `filter` and `startTime` on
8
+ spaces.messages.list are silently ignored, and pageSize is only honoured when
9
+ `orderBy` is present. Hence every read pages newest-first with
10
+ orderBy=createTime desc and does its own client-side windowing.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import random
16
+ import time
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from pathlib import Path
21
+
22
+ from . import accounts
23
+
24
+ CHAT_BASE = "https://chat.googleapis.com/v1"
25
+ GMAIL_BASE = "https://gmail.googleapis.com/gmail/v1"
26
+
27
+ SCOPES_HINT = "chat.messages, chat.spaces.readonly"
28
+
29
+
30
+ def token_path() -> Path:
31
+ """Token for whichever account this process is working on."""
32
+ return accounts.active_token()
33
+
34
+
35
+ class ChatError(RuntimeError):
36
+ def __init__(self, status: int, message: str):
37
+ super().__init__("HTTP %s: %s" % (status, message))
38
+ self.status = status
39
+ self.message = message
40
+
41
+
42
+ class Client:
43
+ """Minimal Chat client with token refresh, retries and a soft rate limit."""
44
+
45
+ def __init__(self, min_interval: float = 0.12, max_retries: int = 6):
46
+ self.min_interval = min_interval
47
+ self.max_retries = max_retries
48
+ self._creds = None
49
+ self._last_call = 0.0
50
+ self.requests = 0
51
+
52
+ # --- auth -------------------------------------------------------------
53
+ def creds(self):
54
+ if self._creds is not None and self._creds.valid:
55
+ return self._creds
56
+ from google.auth.transport.requests import Request
57
+ from google.oauth2.credentials import Credentials
58
+
59
+ creds = Credentials.from_authorized_user_file(str(token_path()))
60
+ if not creds.valid:
61
+ creds.refresh(Request())
62
+ token = json.loads(token_path().read_text())
63
+ token["token"] = creds.token
64
+ token["refresh_token"] = creds.refresh_token or token.get("refresh_token")
65
+ token["scopes"] = sorted(creds.scopes or token.get("scopes", []))
66
+ token_path().write_text(json.dumps(token, indent=2))
67
+ self._creds = creds
68
+ return creds
69
+
70
+ # --- transport --------------------------------------------------------
71
+ def _throttle(self):
72
+ wait = self.min_interval - (time.monotonic() - self._last_call)
73
+ if wait > 0:
74
+ time.sleep(wait)
75
+ self._last_call = time.monotonic()
76
+
77
+ def get(self, url: str, params: dict | None = None) -> dict:
78
+ if params:
79
+ url = url + "?" + urllib.parse.urlencode(params)
80
+ for attempt in range(self.max_retries):
81
+ self._throttle()
82
+ req = urllib.request.Request(url, method="GET")
83
+ req.add_header("Authorization", "Bearer " + self.creds().token)
84
+ self.requests += 1
85
+ try:
86
+ with urllib.request.urlopen(req, timeout=90) as resp:
87
+ raw = resp.read().decode()
88
+ return json.loads(raw) if raw else {}
89
+ except urllib.error.HTTPError as exc:
90
+ detail = exc.read().decode(errors="replace")
91
+ if exc.code in (429, 500, 502, 503, 504):
92
+ time.sleep(min(60, (2 ** attempt) + random.random()))
93
+ self._creds = None # force a token re-read on retry
94
+ continue
95
+ raise ChatError(exc.code, detail[:400]) from exc
96
+ except (urllib.error.URLError, TimeoutError, ConnectionError):
97
+ time.sleep(min(30, (2 ** attempt) + random.random()))
98
+ continue
99
+ raise ChatError(429, "retries exhausted")
100
+
101
+ # --- API --------------------------------------------------------------
102
+ def my_identity(self) -> dict:
103
+ """Who am I? Taken from Gmail's profile (same OAuth token)."""
104
+ try:
105
+ data = self.get(GMAIL_BASE + "/users/me/profile")
106
+ return {"email": data.get("emailAddress"), "name": None}
107
+ except ChatError:
108
+ return {"email": None, "name": None}
109
+
110
+ def iter_spaces(self):
111
+ page_token = None
112
+ while True:
113
+ params = {"pageSize": "1000"}
114
+ if page_token:
115
+ params["pageToken"] = page_token
116
+ data = self.get(CHAT_BASE + "/spaces", params)
117
+ yield from data.get("spaces", [])
118
+ page_token = data.get("nextPageToken")
119
+ if not page_token:
120
+ return
121
+
122
+ def iter_messages(self, space: str, stop_when_known: set[str] | None = None):
123
+ """Yield messages newest-first, paging until exhausted.
124
+
125
+ When `stop_when_known` is given (incremental sync), stop as soon as a
126
+ whole page contains nothing new: everything older is already archived.
127
+ """
128
+ page_token = None
129
+ while True:
130
+ params = {"orderBy": "createTime desc", "pageSize": "1000"}
131
+ if page_token:
132
+ params["pageToken"] = page_token
133
+ data = self.get("%s/%s/messages" % (CHAT_BASE, space), params)
134
+ batch = data.get("messages", [])
135
+ yield from batch
136
+ page_token = data.get("nextPageToken")
137
+ if not page_token:
138
+ return
139
+ if stop_when_known is not None and batch:
140
+ if all(m.get("name") in stop_when_known for m in batch):
141
+ return
142
+ if not batch:
143
+ return