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.
- gchat_archive/__init__.py +3 -0
- gchat_archive/__main__.py +3 -0
- gchat_archive/accounts.py +326 -0
- gchat_archive/api.py +143 -0
- gchat_archive/attachments.py +308 -0
- gchat_archive/auth.py +178 -0
- gchat_archive/backup.py +246 -0
- gchat_archive/browser_source.py +384 -0
- gchat_archive/build.py +464 -0
- gchat_archive/cli.py +372 -0
- gchat_archive/fetch.py +265 -0
- gchat_archive/manage.py +105 -0
- gchat_archive/paths.py +50 -0
- gchat_archive/schedule.py +184 -0
- gchat_archive/serve.py +1629 -0
- gchat_archive/service.py +200 -0
- gchat_archive/textract.py +184 -0
- gchat_archive/web/app.js +1872 -0
- gchat_archive/web/index.html +152 -0
- gchat_archive/web/login.html +176 -0
- gchat_archive/web/setup.html +137 -0
- gchat_archive/web/styles.css +1360 -0
- gchat_archive-0.2.0.dist-info/METADATA +307 -0
- gchat_archive-0.2.0.dist-info/RECORD +28 -0
- gchat_archive-0.2.0.dist-info/WHEEL +5 -0
- gchat_archive-0.2.0.dist-info/entry_points.txt +2 -0
- gchat_archive-0.2.0.dist-info/licenses/LICENSE +21 -0
- gchat_archive-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|