waidrop-cli 1.0.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.
- waidrop_cli/__init__.py +3 -0
- waidrop_cli/__main__.py +4 -0
- waidrop_cli/archive.py +103 -0
- waidrop_cli/auth.py +321 -0
- waidrop_cli/auth_flow.py +182 -0
- waidrop_cli/chats.py +169 -0
- waidrop_cli/cli.py +733 -0
- waidrop_cli/config.py +359 -0
- waidrop_cli/context.py +352 -0
- waidrop_cli/crypto.py +126 -0
- waidrop_cli/dropbox.py +217 -0
- waidrop_cli/env_file.py +132 -0
- waidrop_cli/errors.py +21 -0
- waidrop_cli/fetch.py +214 -0
- waidrop_cli/mcp_cli.py +293 -0
- waidrop_cli/mcp_config.py +191 -0
- waidrop_cli/mcp_server.py +367 -0
- waidrop_cli/media.py +179 -0
- waidrop_cli/output.py +160 -0
- waidrop_cli-1.0.0.dist-info/METADATA +477 -0
- waidrop_cli-1.0.0.dist-info/RECORD +25 -0
- waidrop_cli-1.0.0.dist-info/WHEEL +4 -0
- waidrop_cli-1.0.0.dist-info/entry_points.txt +3 -0
- waidrop_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- waidrop_cli-1.0.0.dist-info/licenses/NOTICE +6 -0
waidrop_cli/config.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Configuration, bound parsing and time-window resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timedelta, tzinfo
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
from .auth import (
|
|
13
|
+
DEFAULT_API_BASE,
|
|
14
|
+
DEFAULT_APP_KEY,
|
|
15
|
+
DEFAULT_CONTENT_BASE,
|
|
16
|
+
DEFAULT_REVOKE_URL,
|
|
17
|
+
DEFAULT_TOKEN_URL,
|
|
18
|
+
load_credentials,
|
|
19
|
+
resolve_credentials_path,
|
|
20
|
+
)
|
|
21
|
+
from .errors import UsageError
|
|
22
|
+
|
|
23
|
+
BOUND_RE = re.compile(
|
|
24
|
+
r"^(\d{4})-(\d{2})-(\d{2})"
|
|
25
|
+
r"(?:[T ](\d{2})(?::(\d{2})(?::(\d{2}))?)?)?$"
|
|
26
|
+
)
|
|
27
|
+
DURATION_RE = re.compile(r"^(\d+)([mhdw])$")
|
|
28
|
+
ACCOUNT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
29
|
+
|
|
30
|
+
PRECISION_UNITS = {
|
|
31
|
+
"date": "days",
|
|
32
|
+
"hour": "hours",
|
|
33
|
+
"minute": "minutes",
|
|
34
|
+
"second": "seconds",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
DEFAULT_ACCOUNT = "primary"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Window:
|
|
42
|
+
start_ms: int # inclusive
|
|
43
|
+
end_ms: int # exclusive
|
|
44
|
+
start: datetime
|
|
45
|
+
end: datetime
|
|
46
|
+
label: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class AuthConfig:
|
|
51
|
+
"""Resolved Dropbox credentials (no archive key required)."""
|
|
52
|
+
|
|
53
|
+
app_key: str | None
|
|
54
|
+
token: str | None
|
|
55
|
+
refresh_token: str | None
|
|
56
|
+
app_secret: str | None
|
|
57
|
+
credentials_path: str
|
|
58
|
+
timeout: float
|
|
59
|
+
api_base: str = DEFAULT_API_BASE
|
|
60
|
+
content_base: str = DEFAULT_CONTENT_BASE
|
|
61
|
+
token_url: str = DEFAULT_TOKEN_URL
|
|
62
|
+
revoke_url: str = DEFAULT_REVOKE_URL
|
|
63
|
+
from_file: bool = False
|
|
64
|
+
token_expires_at: float | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class DropboxConfig:
|
|
69
|
+
token: str | None
|
|
70
|
+
refresh_token: str | None
|
|
71
|
+
app_key: str | None
|
|
72
|
+
app_secret: str | None
|
|
73
|
+
archive_key: str
|
|
74
|
+
account: str
|
|
75
|
+
folder: str
|
|
76
|
+
tz: tzinfo
|
|
77
|
+
timeout: float
|
|
78
|
+
max_parts: int
|
|
79
|
+
api_base: str = DEFAULT_API_BASE
|
|
80
|
+
content_base: str = DEFAULT_CONTENT_BASE
|
|
81
|
+
token_url: str = DEFAULT_TOKEN_URL
|
|
82
|
+
revoke_url: str = DEFAULT_REVOKE_URL
|
|
83
|
+
credentials_path: str | None = None
|
|
84
|
+
from_file: bool = False
|
|
85
|
+
token_expires_at: float | None = None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def account_root(self) -> str:
|
|
89
|
+
return f"{self.folder}/account={self.account}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _dropbox_urls(base: str) -> dict:
|
|
96
|
+
"""Resolve the test endpoint override.
|
|
97
|
+
|
|
98
|
+
WAIDROP_TEST_DROPBOX_BASE redirects every Dropbox call, including the
|
|
99
|
+
OAuth token exchange and the bearer token, so a tampered environment must
|
|
100
|
+
not be able to point it at an arbitrary plaintext host. HTTPS is always
|
|
101
|
+
allowed; HTTP only for loopback test servers.
|
|
102
|
+
"""
|
|
103
|
+
if not base:
|
|
104
|
+
return {}
|
|
105
|
+
parsed = urlsplit(base)
|
|
106
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
107
|
+
raise UsageError("WAIDROP_TEST_DROPBOX_BASE must be an http(s) URL")
|
|
108
|
+
if parsed.scheme == "http" and parsed.hostname not in LOOPBACK_HOSTS:
|
|
109
|
+
raise UsageError(
|
|
110
|
+
"WAIDROP_TEST_DROPBOX_BASE must use https "
|
|
111
|
+
"(http is allowed only for loopback test servers)"
|
|
112
|
+
)
|
|
113
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
114
|
+
raise UsageError(
|
|
115
|
+
"WAIDROP_TEST_DROPBOX_BASE must not contain credentials, a query or a fragment"
|
|
116
|
+
)
|
|
117
|
+
return {
|
|
118
|
+
"api_base": f"{base}/2",
|
|
119
|
+
"content_base": f"{base}/2",
|
|
120
|
+
"token_url": f"{base}/oauth2/token",
|
|
121
|
+
"revoke_url": f"{base}/2/auth/token/revoke",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_auth(args) -> AuthConfig:
|
|
126
|
+
"""Resolve Dropbox credentials from flags, env, the env file, then the local file.
|
|
127
|
+
|
|
128
|
+
Precedence: CLI flag > environment variable > private env file > credentials
|
|
129
|
+
file. An explicit access or refresh token (flag/env) disables file lookups so
|
|
130
|
+
secrets injected by a host are never overwritten.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
env_file_values = getattr(args, "env_file_values", {})
|
|
134
|
+
|
|
135
|
+
def value(flag: str, env: str) -> str:
|
|
136
|
+
return str(
|
|
137
|
+
getattr(args, flag, None) or os.environ.get(env) or env_file_values.get(env) or ""
|
|
138
|
+
).strip()
|
|
139
|
+
|
|
140
|
+
app_key = value("dropbox_app_key", "WAIDROP_DROPBOX_APP_KEY") or None
|
|
141
|
+
token = value("dropbox_token", "WAIDROP_DROPBOX_TOKEN") or None
|
|
142
|
+
refresh_token = value("dropbox_refresh_token", "WAIDROP_DROPBOX_REFRESH_TOKEN") or None
|
|
143
|
+
app_secret = value("dropbox_app_secret", "WAIDROP_DROPBOX_APP_SECRET") or None
|
|
144
|
+
|
|
145
|
+
timeout = getattr(args, "timeout", None)
|
|
146
|
+
timeout = 60.0 if timeout is None else float(timeout)
|
|
147
|
+
|
|
148
|
+
credentials_path = resolve_credentials_path(args)
|
|
149
|
+
from_file = False
|
|
150
|
+
token_expires_at: float | None = None
|
|
151
|
+
if token is None and refresh_token is None:
|
|
152
|
+
creds = load_credentials(credentials_path)
|
|
153
|
+
if creds is not None:
|
|
154
|
+
token = creds.access_token
|
|
155
|
+
refresh_token = creds.refresh_token or None
|
|
156
|
+
token_expires_at = creds.expires_at
|
|
157
|
+
if app_key is None:
|
|
158
|
+
app_key = creds.app_key or None
|
|
159
|
+
from_file = True
|
|
160
|
+
|
|
161
|
+
if app_key is None:
|
|
162
|
+
app_key = DEFAULT_APP_KEY or None
|
|
163
|
+
|
|
164
|
+
base = (os.environ.get("WAIDROP_TEST_DROPBOX_BASE") or "").strip().rstrip("/")
|
|
165
|
+
|
|
166
|
+
return AuthConfig(
|
|
167
|
+
app_key=app_key,
|
|
168
|
+
token=token,
|
|
169
|
+
refresh_token=refresh_token,
|
|
170
|
+
app_secret=app_secret,
|
|
171
|
+
credentials_path=str(credentials_path),
|
|
172
|
+
timeout=timeout,
|
|
173
|
+
from_file=from_file,
|
|
174
|
+
token_expires_at=token_expires_at,
|
|
175
|
+
**_dropbox_urls(base),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse_tz(name: str | None) -> tzinfo:
|
|
180
|
+
if not name:
|
|
181
|
+
return datetime.now().astimezone().tzinfo
|
|
182
|
+
try:
|
|
183
|
+
return ZoneInfo(name)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
raise UsageError(f"unknown timezone: {name}") from exc
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _normalize_folder(value: str | None) -> str:
|
|
189
|
+
folder = str(value or "").strip().strip("/")
|
|
190
|
+
return f"/{folder}" if folder else ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_config(args) -> DropboxConfig:
|
|
194
|
+
auth = resolve_auth(args)
|
|
195
|
+
|
|
196
|
+
if not auth.token and not (auth.refresh_token and auth.app_key):
|
|
197
|
+
raise UsageError(
|
|
198
|
+
"Dropbox is not connected. Run `waidrop auth` to connect, or set "
|
|
199
|
+
"WAIDROP_DROPBOX_TOKEN (or WAIDROP_DROPBOX_REFRESH_TOKEN + "
|
|
200
|
+
"WAIDROP_DROPBOX_APP_KEY) for testing."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
env_file_values = getattr(args, "env_file_values", {})
|
|
204
|
+
|
|
205
|
+
def value(flag: str, env: str) -> str:
|
|
206
|
+
return str(
|
|
207
|
+
getattr(args, flag, None) or os.environ.get(env) or env_file_values.get(env) or ""
|
|
208
|
+
).strip()
|
|
209
|
+
|
|
210
|
+
archive_key = value("key", "WAIDROP_KEY")
|
|
211
|
+
|
|
212
|
+
account = value("account", "WAIDROP_ACCOUNT") or DEFAULT_ACCOUNT
|
|
213
|
+
if not ACCOUNT_RE.match(account):
|
|
214
|
+
raise UsageError("account alias may only contain letters, digits and . _ -")
|
|
215
|
+
|
|
216
|
+
folder = _normalize_folder(value("folder", "WAIDROP_FOLDER"))
|
|
217
|
+
|
|
218
|
+
if auth.timeout <= 0:
|
|
219
|
+
raise UsageError("--timeout must be positive")
|
|
220
|
+
|
|
221
|
+
max_parts = getattr(args, "max_parts", 1000)
|
|
222
|
+
if not isinstance(max_parts, int) or max_parts < 1:
|
|
223
|
+
raise UsageError("--max-parts must be a positive integer")
|
|
224
|
+
|
|
225
|
+
return DropboxConfig(
|
|
226
|
+
token=auth.token,
|
|
227
|
+
refresh_token=auth.refresh_token,
|
|
228
|
+
app_key=auth.app_key,
|
|
229
|
+
app_secret=auth.app_secret,
|
|
230
|
+
archive_key=archive_key,
|
|
231
|
+
account=account,
|
|
232
|
+
folder=folder,
|
|
233
|
+
tz=parse_tz(getattr(args, "tz", None)),
|
|
234
|
+
timeout=auth.timeout,
|
|
235
|
+
max_parts=max_parts,
|
|
236
|
+
api_base=auth.api_base,
|
|
237
|
+
content_base=auth.content_base,
|
|
238
|
+
token_url=auth.token_url,
|
|
239
|
+
revoke_url=auth.revoke_url,
|
|
240
|
+
credentials_path=auth.credentials_path,
|
|
241
|
+
from_file=auth.from_file,
|
|
242
|
+
token_expires_at=auth.token_expires_at,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def parse_bound(text: str, tz: tzinfo) -> tuple[datetime, str]:
|
|
247
|
+
"""Parse a bound and return (datetime, precision).
|
|
248
|
+
|
|
249
|
+
Accepted: YYYY-MM-DD, YYYY-MM-DDTHH, YYYY-MM-DDTHH:MM,
|
|
250
|
+
YYYY-MM-DDTHH:MM:SS (space separator also allowed).
|
|
251
|
+
"""
|
|
252
|
+
match = BOUND_RE.match((text or "").strip())
|
|
253
|
+
if not match:
|
|
254
|
+
raise UsageError(
|
|
255
|
+
f"invalid date/time {text!r}; use YYYY-MM-DD, YYYY-MM-DDTHH, "
|
|
256
|
+
"YYYY-MM-DDTHH:MM or YYYY-MM-DDTHH:MM:SS"
|
|
257
|
+
)
|
|
258
|
+
year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
259
|
+
hour = int(match.group(4) or 0)
|
|
260
|
+
minute = int(match.group(5) or 0)
|
|
261
|
+
second = int(match.group(6) or 0)
|
|
262
|
+
try:
|
|
263
|
+
moment = datetime(year, month, day, hour, minute, second, tzinfo=tz)
|
|
264
|
+
except ValueError as exc:
|
|
265
|
+
raise UsageError(f"invalid date/time {text!r}: {exc}") from exc
|
|
266
|
+
|
|
267
|
+
if match.group(6) is not None:
|
|
268
|
+
precision = "second"
|
|
269
|
+
elif match.group(5) is not None:
|
|
270
|
+
precision = "minute"
|
|
271
|
+
elif match.group(4) is not None:
|
|
272
|
+
precision = "hour"
|
|
273
|
+
else:
|
|
274
|
+
precision = "date"
|
|
275
|
+
return moment, precision
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _precision_delta(precision: str) -> timedelta:
|
|
279
|
+
return timedelta(**{PRECISION_UNITS[precision]: 1})
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def snap_start(moment: datetime, precision: str) -> datetime:
|
|
283
|
+
if precision == "date":
|
|
284
|
+
return moment.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
285
|
+
if precision == "hour":
|
|
286
|
+
return moment.replace(minute=0, second=0, microsecond=0)
|
|
287
|
+
if precision == "minute":
|
|
288
|
+
return moment.replace(second=0, microsecond=0)
|
|
289
|
+
return moment.replace(microsecond=0)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def snap_end_exclusive(moment: datetime, precision: str) -> datetime:
|
|
293
|
+
return snap_start(moment, precision) + _precision_delta(precision)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def parse_duration(text: str) -> timedelta:
|
|
297
|
+
match = DURATION_RE.match((text or "").strip().lower())
|
|
298
|
+
if not match:
|
|
299
|
+
raise UsageError(f"invalid --last {text!r}; use e.g. 90m, 36h, 7d, 2w")
|
|
300
|
+
amount = int(match.group(1))
|
|
301
|
+
if amount < 1:
|
|
302
|
+
raise UsageError("--last must be at least 1 unit")
|
|
303
|
+
unit = match.group(2)
|
|
304
|
+
unit_name = {"m": "minutes", "h": "hours", "d": "days", "w": "weeks"}[unit]
|
|
305
|
+
return timedelta(**{unit_name: amount})
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def parse_context_before(text: str | None) -> timedelta:
|
|
309
|
+
value = (text or "").strip().lower()
|
|
310
|
+
if value in ("", "0"):
|
|
311
|
+
return timedelta(0)
|
|
312
|
+
match = DURATION_RE.match(value)
|
|
313
|
+
if not match:
|
|
314
|
+
raise UsageError(f"invalid --context-before {text!r}; use 0 or e.g. 30m, 36h, 7d, 2w")
|
|
315
|
+
amount = int(match.group(1))
|
|
316
|
+
unit = match.group(2)
|
|
317
|
+
unit_name = {"m": "minutes", "h": "hours", "d": "days", "w": "weeks"}[unit]
|
|
318
|
+
return timedelta(**{unit_name: amount})
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _ms(moment: datetime) -> int:
|
|
322
|
+
return int(moment.timestamp() * 1000)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def resolve_window(
|
|
326
|
+
from_text: str | None,
|
|
327
|
+
to_text: str | None,
|
|
328
|
+
last_text: str | None,
|
|
329
|
+
tz: tzinfo,
|
|
330
|
+
now: datetime | None = None,
|
|
331
|
+
) -> Window:
|
|
332
|
+
now = now or datetime.now(tz)
|
|
333
|
+
|
|
334
|
+
if last_text:
|
|
335
|
+
if from_text or to_text:
|
|
336
|
+
raise UsageError("--last cannot be combined with --from/--to")
|
|
337
|
+
duration = parse_duration(last_text)
|
|
338
|
+
end = now
|
|
339
|
+
start = end - duration
|
|
340
|
+
label = f"last {last_text} ({start.isoformat()} .. {end.isoformat()})"
|
|
341
|
+
return Window(_ms(start), _ms(end) + 1, start, end, label)
|
|
342
|
+
|
|
343
|
+
if not from_text:
|
|
344
|
+
raise UsageError("provide --from or --last")
|
|
345
|
+
|
|
346
|
+
start_dt, start_precision = parse_bound(from_text, tz)
|
|
347
|
+
start = snap_start(start_dt, start_precision)
|
|
348
|
+
|
|
349
|
+
if to_text:
|
|
350
|
+
to_dt, to_precision = parse_bound(to_text, tz)
|
|
351
|
+
end = snap_end_exclusive(to_dt, to_precision)
|
|
352
|
+
else:
|
|
353
|
+
end = now
|
|
354
|
+
|
|
355
|
+
if end <= start:
|
|
356
|
+
raise UsageError("--to must be after --from")
|
|
357
|
+
|
|
358
|
+
label = f"{start.isoformat()} .. {end.isoformat()} (end exclusive)"
|
|
359
|
+
return Window(_ms(start), _ms(end), start, end, label)
|
waidrop_cli/context.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Group archive records into per-chat conversation chunks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import tzinfo
|
|
8
|
+
|
|
9
|
+
from .chats import ChatEntry
|
|
10
|
+
from .config import Window
|
|
11
|
+
from .fetch import effective_ts
|
|
12
|
+
from .output import escape_text, record_body, record_moment, safe_cell
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ChatMeta:
|
|
17
|
+
hash: str | None
|
|
18
|
+
id: str | None
|
|
19
|
+
name: str
|
|
20
|
+
phone: str | None
|
|
21
|
+
pushname: str | None
|
|
22
|
+
is_group: bool
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def key(self) -> str:
|
|
26
|
+
if self.hash:
|
|
27
|
+
return self.hash
|
|
28
|
+
if self.id:
|
|
29
|
+
return self.id
|
|
30
|
+
return f"name:{self.name.casefold()}"
|
|
31
|
+
|
|
32
|
+
def as_dict(self) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"hash": self.hash,
|
|
35
|
+
"id": self.id,
|
|
36
|
+
"name": self.name,
|
|
37
|
+
"phone": self.phone,
|
|
38
|
+
"pushname": self.pushname,
|
|
39
|
+
"isGroup": self.is_group,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ChatThread:
|
|
45
|
+
meta: ChatMeta
|
|
46
|
+
messages: list[dict] = field(default_factory=list)
|
|
47
|
+
last_ts: int = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ContextChunk:
|
|
52
|
+
chunk_id: str
|
|
53
|
+
meta: ChatMeta
|
|
54
|
+
window: Window
|
|
55
|
+
context_before_ms: int
|
|
56
|
+
part: int
|
|
57
|
+
parts: int
|
|
58
|
+
oversized: bool
|
|
59
|
+
messages: list[dict]
|
|
60
|
+
|
|
61
|
+
def as_dict(self) -> dict:
|
|
62
|
+
return {
|
|
63
|
+
"chunkVersion": 1,
|
|
64
|
+
"chunkId": self.chunk_id,
|
|
65
|
+
"chat": self.meta.as_dict(),
|
|
66
|
+
"window": {
|
|
67
|
+
"start": self.window.start_ms,
|
|
68
|
+
"end": self.window.end_ms,
|
|
69
|
+
"startIso": self.window.start.isoformat(),
|
|
70
|
+
"endIso": self.window.end.isoformat(),
|
|
71
|
+
},
|
|
72
|
+
"contextBeforeMs": self.context_before_ms,
|
|
73
|
+
"part": self.part,
|
|
74
|
+
"parts": self.parts,
|
|
75
|
+
"oversized": self.oversized,
|
|
76
|
+
"messages": self.messages,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _entry_maps(selected: list[ChatEntry]) -> tuple[dict[str, ChatEntry], dict[str, ChatEntry]]:
|
|
81
|
+
by_id = {entry.chat_id: entry for entry in selected if entry.chat_id}
|
|
82
|
+
counts: dict[str, int] = {}
|
|
83
|
+
for entry in selected:
|
|
84
|
+
name = entry.chat_name.casefold()
|
|
85
|
+
counts[name] = counts.get(name, 0) + 1
|
|
86
|
+
by_name = {
|
|
87
|
+
entry.chat_name.casefold(): entry
|
|
88
|
+
for entry in selected
|
|
89
|
+
if entry.chat_name and counts[entry.chat_name.casefold()] == 1
|
|
90
|
+
}
|
|
91
|
+
return by_id, by_name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _entry_meta(entry: ChatEntry) -> ChatMeta:
|
|
95
|
+
return ChatMeta(
|
|
96
|
+
hash=entry.hash or None,
|
|
97
|
+
id=entry.chat_id or None,
|
|
98
|
+
name=entry.chat_name or "unknown",
|
|
99
|
+
phone=entry.chat_phone or None,
|
|
100
|
+
pushname=entry.chat_pushname or None,
|
|
101
|
+
is_group=entry.is_group,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
CONTEXT_MEDIA_TYPES = {"image", "sticker", "gif", "media"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_media_record(record: dict) -> bool:
|
|
109
|
+
return record.get("type") in CONTEXT_MEDIA_TYPES
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def attach_media_exports(threads: list[ChatThread], entries: list[dict]) -> None:
|
|
113
|
+
"""Attach export results to media messages in the given threads."""
|
|
114
|
+
by_id = {entry.get("msgId"): entry for entry in entries if entry.get("msgId")}
|
|
115
|
+
for thread in threads:
|
|
116
|
+
for message in thread.messages:
|
|
117
|
+
if not is_media_record(message):
|
|
118
|
+
continue
|
|
119
|
+
entry = by_id.get(message.get("msgId"))
|
|
120
|
+
if entry is None:
|
|
121
|
+
message["mediaExport"] = {
|
|
122
|
+
"status": "skipped",
|
|
123
|
+
"file": None,
|
|
124
|
+
"error": "not considered for export",
|
|
125
|
+
}
|
|
126
|
+
continue
|
|
127
|
+
message["mediaExport"] = {
|
|
128
|
+
"status": entry.get("status") or "skipped",
|
|
129
|
+
"file": entry.get("file"),
|
|
130
|
+
"error": entry.get("error"),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def group_records(
|
|
135
|
+
records: list[dict], selected: list[ChatEntry], window: Window
|
|
136
|
+
) -> list[ChatThread]:
|
|
137
|
+
"""Group records by chat, keep only chats with messages in the window."""
|
|
138
|
+
by_id, by_name = _entry_maps(selected)
|
|
139
|
+
threads: dict[str, ChatThread] = {}
|
|
140
|
+
|
|
141
|
+
for record in records:
|
|
142
|
+
ts = effective_ts(record)
|
|
143
|
+
if ts is None:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
raw_chat_id = record.get("chatId")
|
|
147
|
+
chat_id = raw_chat_id if isinstance(raw_chat_id, str) and raw_chat_id else None
|
|
148
|
+
name = str(record.get("chatName") or "").strip()
|
|
149
|
+
|
|
150
|
+
entry = by_id.get(chat_id) if chat_id else None
|
|
151
|
+
if entry is None and chat_id is None and name:
|
|
152
|
+
entry = by_name.get(name.casefold())
|
|
153
|
+
|
|
154
|
+
if entry is not None:
|
|
155
|
+
key = entry.chat_id
|
|
156
|
+
meta = _entry_meta(entry)
|
|
157
|
+
else:
|
|
158
|
+
label = name or "unknown"
|
|
159
|
+
key = chat_id or f"name:{label.casefold()}"
|
|
160
|
+
meta = ChatMeta(
|
|
161
|
+
hash=None,
|
|
162
|
+
id=chat_id,
|
|
163
|
+
name=label,
|
|
164
|
+
phone=str(record.get("chatPhone") or "") or None,
|
|
165
|
+
pushname=str(record.get("chatPushname") or "") or None,
|
|
166
|
+
is_group=bool(record.get("isGroup")) or bool(chat_id and chat_id.endswith("@g.us")),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
thread = threads.get(key)
|
|
170
|
+
if thread is None:
|
|
171
|
+
thread = ChatThread(meta=meta)
|
|
172
|
+
threads[key] = thread
|
|
173
|
+
|
|
174
|
+
message = dict(record)
|
|
175
|
+
message["inWindow"] = bool(window.start_ms <= ts < window.end_ms)
|
|
176
|
+
thread.messages.append(message)
|
|
177
|
+
|
|
178
|
+
result: list[ChatThread] = []
|
|
179
|
+
for thread in threads.values():
|
|
180
|
+
thread.messages.sort(key=lambda item: (effective_ts(item) or 0, item.get("msgId") or ""))
|
|
181
|
+
in_window_ts = [effective_ts(item) for item in thread.messages if item.get("inWindow")]
|
|
182
|
+
if not in_window_ts:
|
|
183
|
+
continue
|
|
184
|
+
thread.last_ts = int(max(in_window_ts))
|
|
185
|
+
result.append(thread)
|
|
186
|
+
|
|
187
|
+
result.sort(key=lambda item: (-item.last_ts, item.meta.key))
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _escape_inline(value: object) -> str:
|
|
192
|
+
return escape_text(value)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _safe_header(value: object) -> str:
|
|
196
|
+
return safe_cell(str(value).replace("|", "/"))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _quote_path(path: object) -> str:
|
|
200
|
+
return json.dumps(str(path), ensure_ascii=False)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _media_annotation(record: dict) -> str:
|
|
204
|
+
export = record.get("mediaExport")
|
|
205
|
+
if not isinstance(export, dict):
|
|
206
|
+
return ""
|
|
207
|
+
file = export.get("file")
|
|
208
|
+
if file:
|
|
209
|
+
return f" (file: {_quote_path(file)})"
|
|
210
|
+
|
|
211
|
+
media = record.get("media") if isinstance(record.get("media"), dict) else {}
|
|
212
|
+
status = _escape_inline(media.get("status") or "")
|
|
213
|
+
if status and status != "stored":
|
|
214
|
+
return f" (media: {status})"
|
|
215
|
+
if export.get("status") == "failed":
|
|
216
|
+
return " (media: export failed)"
|
|
217
|
+
if export.get("status") == "skipped":
|
|
218
|
+
if not status:
|
|
219
|
+
return " (media: unavailable)"
|
|
220
|
+
return " (media: not exported)"
|
|
221
|
+
return ""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _message_line(record: dict, tz: tzinfo, context: bool) -> str:
|
|
225
|
+
moment = record_moment(record, tz)
|
|
226
|
+
stamp = moment.strftime("%Y-%m-%d %H:%M") if moment else "????-??-?? ??:??"
|
|
227
|
+
direction = record.get("direction")
|
|
228
|
+
glyph = "→" if direction == "out" else "←" if direction == "in" else "·"
|
|
229
|
+
sender = "You" if direction == "out" else (record.get("senderName") or "Unknown")
|
|
230
|
+
marker = "[context] " if context else ""
|
|
231
|
+
body = _escape_inline(record_body(record))
|
|
232
|
+
return f"[{stamp}] {marker}{glyph} {_escape_inline(sender)}: {body}{_media_annotation(record)}"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _duration_label(milliseconds: int) -> str:
|
|
236
|
+
seconds = max(milliseconds, 0) // 1000
|
|
237
|
+
for unit_seconds, suffix in ((604800, "w"), (86400, "d"), (3600, "h"), (60, "m")):
|
|
238
|
+
if seconds and seconds % unit_seconds == 0:
|
|
239
|
+
return f"{seconds // unit_seconds}{suffix}"
|
|
240
|
+
return f"{seconds}s"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _window_label(window: Window, tz: tzinfo) -> str:
|
|
244
|
+
start = window.start.astimezone(tz)
|
|
245
|
+
end = window.end.astimezone(tz)
|
|
246
|
+
zone = start.tzname() or start.strftime("%z")
|
|
247
|
+
if start.date() == end.date():
|
|
248
|
+
span = f"{start:%Y-%m-%d %H:%M}–{end:%H:%M}"
|
|
249
|
+
else:
|
|
250
|
+
span = f"{start:%Y-%m-%d %H:%M}–{end:%Y-%m-%d %H:%M}"
|
|
251
|
+
return f"{span} {zone}".strip()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _pack_messages(
|
|
255
|
+
messages: list[dict], max_chars: int, tz: tzinfo
|
|
256
|
+
) -> list[tuple[list[dict], bool]]:
|
|
257
|
+
if max_chars <= 0:
|
|
258
|
+
return [(messages, False)]
|
|
259
|
+
|
|
260
|
+
parts: list[tuple[list[dict], bool]] = []
|
|
261
|
+
current: list[dict] = []
|
|
262
|
+
current_len = 0
|
|
263
|
+
|
|
264
|
+
for message in messages:
|
|
265
|
+
line_len = len(_message_line(message, tz, context=not message.get("inWindow", True)))
|
|
266
|
+
if line_len > max_chars:
|
|
267
|
+
if current:
|
|
268
|
+
parts.append((current, False))
|
|
269
|
+
current = []
|
|
270
|
+
current_len = 0
|
|
271
|
+
parts.append(([message], True))
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
extra = line_len + (1 if current else 0)
|
|
275
|
+
if current and current_len + extra > max_chars:
|
|
276
|
+
parts.append((current, False))
|
|
277
|
+
current = [message]
|
|
278
|
+
current_len = line_len
|
|
279
|
+
else:
|
|
280
|
+
current.append(message)
|
|
281
|
+
current_len += extra
|
|
282
|
+
|
|
283
|
+
if current:
|
|
284
|
+
parts.append((current, False))
|
|
285
|
+
return parts or [(messages, False)]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def build_chunks(
|
|
289
|
+
threads: list[ChatThread],
|
|
290
|
+
window: Window,
|
|
291
|
+
context_before_ms: int,
|
|
292
|
+
max_chars: int,
|
|
293
|
+
tz: tzinfo,
|
|
294
|
+
) -> list[ContextChunk]:
|
|
295
|
+
chunks: list[ContextChunk] = []
|
|
296
|
+
for thread in threads:
|
|
297
|
+
packed = _pack_messages(thread.messages, max_chars, tz)
|
|
298
|
+
total = len(packed)
|
|
299
|
+
for index, (messages, oversized) in enumerate(packed, 1):
|
|
300
|
+
first_ts = int(effective_ts(messages[0]) or 0)
|
|
301
|
+
last_ts = int(effective_ts(messages[-1]) or first_ts)
|
|
302
|
+
chunks.append(
|
|
303
|
+
ContextChunk(
|
|
304
|
+
chunk_id=f"{thread.meta.key}:{first_ts}:{last_ts}:{index}",
|
|
305
|
+
meta=thread.meta,
|
|
306
|
+
window=window,
|
|
307
|
+
context_before_ms=context_before_ms,
|
|
308
|
+
part=index,
|
|
309
|
+
parts=total,
|
|
310
|
+
oversized=oversized,
|
|
311
|
+
messages=messages,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
return chunks
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def render_chunks_ndjson(chunks: list[ContextChunk]) -> list[str]:
|
|
318
|
+
return [json.dumps(chunk.as_dict(), ensure_ascii=False) for chunk in chunks]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def render_chunks_text(chunks: list[ContextChunk], tz: tzinfo) -> list[str]:
|
|
322
|
+
lines: list[str] = []
|
|
323
|
+
for chunk in chunks:
|
|
324
|
+
if lines:
|
|
325
|
+
lines.append("")
|
|
326
|
+
|
|
327
|
+
identity = chunk.meta.hash or chunk.meta.id or chunk.meta.name
|
|
328
|
+
kind = "group" if chunk.meta.is_group else "dm"
|
|
329
|
+
part = f" | part {chunk.part}/{chunk.parts}" if chunk.parts > 1 else ""
|
|
330
|
+
lines.append(
|
|
331
|
+
"=== CHAT "
|
|
332
|
+
f"{_safe_header(identity)} | {_safe_header(chunk.meta.name)} | {kind} | "
|
|
333
|
+
f"{_safe_header(chunk.meta.phone or '-')}{part} ==="
|
|
334
|
+
)
|
|
335
|
+
lines.append(f"[window {_window_label(chunk.window, tz)}]")
|
|
336
|
+
|
|
337
|
+
has_context = any(not message.get("inWindow", True) for message in chunk.messages)
|
|
338
|
+
if chunk.context_before_ms > 0 and has_context:
|
|
339
|
+
lines.append(f"[context before {_duration_label(chunk.context_before_ms)}]")
|
|
340
|
+
|
|
341
|
+
for message in chunk.messages:
|
|
342
|
+
lines.append(_message_line(message, tz, context=not message.get("inWindow", True)))
|
|
343
|
+
|
|
344
|
+
if chunk.part < chunk.parts:
|
|
345
|
+
lines.append(
|
|
346
|
+
f"=== END PART {chunk.part}/{chunk.parts} "
|
|
347
|
+
f"(continues in part {chunk.part + 1}/{chunk.parts}) ==="
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
suffix = ", oversized message" if chunk.oversized else ""
|
|
351
|
+
lines.append(f"=== END CHAT ({chunk.part}/{chunk.parts}{suffix}) ===")
|
|
352
|
+
return lines
|