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/cli.py
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"""waidrop command line interface (encrypted Dropbox archive)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .archive import ArchiveCodec, resolve_archive
|
|
15
|
+
from .auth import (
|
|
16
|
+
Credentials,
|
|
17
|
+
delete_credentials,
|
|
18
|
+
load_credentials,
|
|
19
|
+
missing_scopes,
|
|
20
|
+
save_credentials,
|
|
21
|
+
)
|
|
22
|
+
from .auth_flow import run_loopback_flow, run_paste_flow
|
|
23
|
+
from .chats import ChatEntry, load_directory, select_chats
|
|
24
|
+
from .config import (
|
|
25
|
+
ACCOUNT_RE,
|
|
26
|
+
DEFAULT_ACCOUNT,
|
|
27
|
+
AuthConfig,
|
|
28
|
+
DropboxConfig,
|
|
29
|
+
Window,
|
|
30
|
+
load_config,
|
|
31
|
+
parse_context_before,
|
|
32
|
+
resolve_auth,
|
|
33
|
+
resolve_window,
|
|
34
|
+
)
|
|
35
|
+
from .context import (
|
|
36
|
+
attach_media_exports,
|
|
37
|
+
build_chunks,
|
|
38
|
+
group_records,
|
|
39
|
+
is_media_record,
|
|
40
|
+
render_chunks_ndjson,
|
|
41
|
+
render_chunks_text,
|
|
42
|
+
)
|
|
43
|
+
from .crypto import is_valid_key
|
|
44
|
+
from .dropbox import DropboxClient
|
|
45
|
+
from .env_file import default_env_path, load_env_file, read_env_file_unchecked, write_env_file
|
|
46
|
+
from .errors import CliError, NoMatchesError, RemoteError, UsageError
|
|
47
|
+
from .fetch import collect, plan_tasks
|
|
48
|
+
from .media import export_media, write_manifest
|
|
49
|
+
from .output import render, safe_cell, write_lines
|
|
50
|
+
|
|
51
|
+
CHAT_HEADERS = ["NAME", "PHONE", "TYPE", "HASH", "CHAT ID"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _add_window_arguments(parser: argparse.ArgumentParser) -> None:
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--from",
|
|
57
|
+
dest="from_text",
|
|
58
|
+
metavar="BOUND",
|
|
59
|
+
help="start bound: YYYY-MM-DD[THH[:MM[:SS]]] (required unless --last)",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--to",
|
|
63
|
+
dest="to_text",
|
|
64
|
+
metavar="BOUND",
|
|
65
|
+
help="end bound (inclusive by precision; default: now)",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--last",
|
|
69
|
+
dest="last_text",
|
|
70
|
+
metavar="DURATION",
|
|
71
|
+
help="relative window, e.g. 90m, 36h, 7d, 2w (excludes --from/--to)",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
76
|
+
parser = argparse.ArgumentParser(
|
|
77
|
+
prog="waidrop",
|
|
78
|
+
description="Query an encrypted WhatsApp archive stored in a Dropbox app folder.",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
81
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
82
|
+
|
|
83
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
84
|
+
common.add_argument(
|
|
85
|
+
"--dropbox-token",
|
|
86
|
+
help="Dropbox access token (or WAIDROP_DROPBOX_TOKEN; env is safer, argv is visible to ps)",
|
|
87
|
+
)
|
|
88
|
+
common.add_argument(
|
|
89
|
+
"--dropbox-refresh-token",
|
|
90
|
+
help="Dropbox refresh token (or WAIDROP_DROPBOX_REFRESH_TOKEN; env is safer than argv)",
|
|
91
|
+
)
|
|
92
|
+
common.add_argument("--dropbox-app-key", help="Dropbox app key (or WAIDROP_DROPBOX_APP_KEY)")
|
|
93
|
+
common.add_argument(
|
|
94
|
+
"--dropbox-app-secret",
|
|
95
|
+
help="Dropbox app secret (or WAIDROP_DROPBOX_APP_SECRET; env is safer than argv)",
|
|
96
|
+
)
|
|
97
|
+
common.add_argument(
|
|
98
|
+
"--credentials-file", help="Dropbox credentials file (or WAIDROP_CREDENTIALS)"
|
|
99
|
+
)
|
|
100
|
+
common.add_argument(
|
|
101
|
+
"--key",
|
|
102
|
+
help="archive encryption key for encrypted archives, base64 32 bytes (or WAIDROP_KEY)",
|
|
103
|
+
)
|
|
104
|
+
common.add_argument(
|
|
105
|
+
"--account", help="account alias folder (or WAIDROP_ACCOUNT; default: primary)"
|
|
106
|
+
)
|
|
107
|
+
common.add_argument("--folder", help="subfolder inside the app folder (or WAIDROP_FOLDER)")
|
|
108
|
+
common.add_argument("--tz", help="IANA timezone for date bounds (default: system local)")
|
|
109
|
+
common.add_argument("--timeout", type=float, default=60.0, help="HTTP timeout in seconds")
|
|
110
|
+
common.add_argument(
|
|
111
|
+
"--max-parts", type=int, default=1000, help="abort if more part objects would be fetched"
|
|
112
|
+
)
|
|
113
|
+
common.add_argument("--quiet", action="store_true", help="suppress progress lines on stderr")
|
|
114
|
+
|
|
115
|
+
selectors = argparse.ArgumentParser(add_help=False)
|
|
116
|
+
selectors.add_argument(
|
|
117
|
+
"--name",
|
|
118
|
+
action="append",
|
|
119
|
+
default=[],
|
|
120
|
+
metavar="GLOB",
|
|
121
|
+
help="chat name glob/substring (repeatable, case-insensitive)",
|
|
122
|
+
)
|
|
123
|
+
selectors.add_argument(
|
|
124
|
+
"--number",
|
|
125
|
+
action="append",
|
|
126
|
+
default=[],
|
|
127
|
+
metavar="PHONE",
|
|
128
|
+
help="phone number, any format (repeatable)",
|
|
129
|
+
)
|
|
130
|
+
selectors.add_argument(
|
|
131
|
+
"--chat", action="append", default=[], metavar="HASH", help="chat hash folder (repeatable)"
|
|
132
|
+
)
|
|
133
|
+
selectors.add_argument(
|
|
134
|
+
"--jid",
|
|
135
|
+
action="append",
|
|
136
|
+
default=[],
|
|
137
|
+
metavar="JID",
|
|
138
|
+
help="raw chat jid, e.g. 49171234567@c.us (repeatable)",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
subparsers.add_parser(
|
|
142
|
+
"chats",
|
|
143
|
+
parents=[common, selectors],
|
|
144
|
+
help="resolve chats to hashes without fetching messages",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
query = subparsers.add_parser("query", parents=[common, selectors], help="fetch messages")
|
|
148
|
+
_add_window_arguments(query)
|
|
149
|
+
query.add_argument("--format", choices=["ndjson", "text", "csv"], default="ndjson")
|
|
150
|
+
query.add_argument("--out", metavar="FILE", help="write output to a file instead of stdout")
|
|
151
|
+
query.add_argument("--media-out", metavar="DIR", help="export full-resolution media into DIR")
|
|
152
|
+
query.add_argument(
|
|
153
|
+
"--media-manifest",
|
|
154
|
+
metavar="FILE",
|
|
155
|
+
help="manifest path (default: DIR/manifest.ndjson)",
|
|
156
|
+
)
|
|
157
|
+
query.add_argument(
|
|
158
|
+
"--media-max-bytes",
|
|
159
|
+
type=int,
|
|
160
|
+
default=25 * 1024 * 1024,
|
|
161
|
+
help="skip media larger than this many bytes (default: 25 MiB)",
|
|
162
|
+
)
|
|
163
|
+
query.add_argument(
|
|
164
|
+
"--media-fail-fast",
|
|
165
|
+
action="store_true",
|
|
166
|
+
help="abort on the first media export error",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
context = subparsers.add_parser(
|
|
170
|
+
"context", parents=[common, selectors], help="fetch per-chat conversation chunks"
|
|
171
|
+
)
|
|
172
|
+
_add_window_arguments(context)
|
|
173
|
+
context.add_argument(
|
|
174
|
+
"--context-before",
|
|
175
|
+
dest="context_before",
|
|
176
|
+
default="0",
|
|
177
|
+
metavar="DURATION",
|
|
178
|
+
help="include this much before the analysis window as context (default: 0)",
|
|
179
|
+
)
|
|
180
|
+
context.add_argument(
|
|
181
|
+
"--max-chars",
|
|
182
|
+
type=int,
|
|
183
|
+
default=0,
|
|
184
|
+
metavar="N",
|
|
185
|
+
help="split each chat into parts of at most N rendered message characters (default: 0 = no split)",
|
|
186
|
+
)
|
|
187
|
+
context.add_argument("--format", choices=["ndjson", "text"], default="ndjson")
|
|
188
|
+
context.add_argument("--out", metavar="FILE", help="write output to a file instead of stdout")
|
|
189
|
+
context.add_argument(
|
|
190
|
+
"--media-out",
|
|
191
|
+
metavar="DIR",
|
|
192
|
+
help="export images/stickers/GIFs referenced by the chunks into DIR",
|
|
193
|
+
)
|
|
194
|
+
context.add_argument(
|
|
195
|
+
"--media-manifest",
|
|
196
|
+
metavar="FILE",
|
|
197
|
+
help="manifest path (default: DIR/manifest.ndjson)",
|
|
198
|
+
)
|
|
199
|
+
context.add_argument(
|
|
200
|
+
"--media-max-bytes",
|
|
201
|
+
type=int,
|
|
202
|
+
default=25 * 1024 * 1024,
|
|
203
|
+
help="skip media larger than this many bytes (default: 25 MiB)",
|
|
204
|
+
)
|
|
205
|
+
context.add_argument(
|
|
206
|
+
"--media-fail-fast",
|
|
207
|
+
action="store_true",
|
|
208
|
+
help="abort on the first media export error",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
auth = subparsers.add_parser(
|
|
212
|
+
"auth", help="connect to Dropbox (OAuth PKCE) or manage credentials"
|
|
213
|
+
)
|
|
214
|
+
auth.add_argument("--status", action="store_true", help="show account, scopes and token health")
|
|
215
|
+
auth.add_argument(
|
|
216
|
+
"--revoke",
|
|
217
|
+
action="store_true",
|
|
218
|
+
help="revoke the Dropbox token and delete local credentials",
|
|
219
|
+
)
|
|
220
|
+
auth.add_argument(
|
|
221
|
+
"--no-browser", action="store_true", help="do not open a browser; print the URL only"
|
|
222
|
+
)
|
|
223
|
+
auth.add_argument(
|
|
224
|
+
"--loopback",
|
|
225
|
+
action="store_true",
|
|
226
|
+
help="use a localhost callback instead of pasting the code",
|
|
227
|
+
)
|
|
228
|
+
auth.add_argument(
|
|
229
|
+
"--port", type=int, default=53682, help="loopback callback port (default: 53682)"
|
|
230
|
+
)
|
|
231
|
+
auth.add_argument(
|
|
232
|
+
"--code", metavar="CODE", help="authorization code to exchange (non-interactive)"
|
|
233
|
+
)
|
|
234
|
+
auth.add_argument(
|
|
235
|
+
"--json", action="store_true", dest="as_json", help="machine-readable output for --status"
|
|
236
|
+
)
|
|
237
|
+
auth.add_argument("--dropbox-app-key", help="Dropbox app key (or WAIDROP_DROPBOX_APP_KEY)")
|
|
238
|
+
auth.add_argument(
|
|
239
|
+
"--dropbox-app-secret",
|
|
240
|
+
help="Dropbox app secret (or WAIDROP_DROPBOX_APP_SECRET); not needed for the built-in app",
|
|
241
|
+
)
|
|
242
|
+
auth.add_argument(
|
|
243
|
+
"--credentials-file", help="Dropbox credentials file (or WAIDROP_CREDENTIALS)"
|
|
244
|
+
)
|
|
245
|
+
auth.add_argument("--timeout", type=float, default=60.0, help="HTTP timeout in seconds")
|
|
246
|
+
|
|
247
|
+
config = subparsers.add_parser(
|
|
248
|
+
"config", help="store the archive key and account in the CLI env file"
|
|
249
|
+
)
|
|
250
|
+
config.add_argument(
|
|
251
|
+
"--key",
|
|
252
|
+
help="archive encryption key, base64 32 bytes (or WAIDROP_KEY; prompted if omitted)",
|
|
253
|
+
)
|
|
254
|
+
config.add_argument(
|
|
255
|
+
"--account",
|
|
256
|
+
help="account alias (or WAIDROP_ACCOUNT; prompted if unset; default: existing value or primary)",
|
|
257
|
+
)
|
|
258
|
+
config.add_argument(
|
|
259
|
+
"--env-file",
|
|
260
|
+
help="env file path (default: next to the credentials file)",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return parser
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _open_archive(args):
|
|
267
|
+
cfg = load_config(args)
|
|
268
|
+
client = DropboxClient(cfg)
|
|
269
|
+
codec = resolve_archive(client, cfg)
|
|
270
|
+
return cfg, client, codec
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _print_chats(entries: list[ChatEntry]) -> None:
|
|
274
|
+
rows = [
|
|
275
|
+
[
|
|
276
|
+
safe_cell(entry.chat_name),
|
|
277
|
+
safe_cell(entry.chat_phone or "-"),
|
|
278
|
+
"group" if entry.is_group else "dm",
|
|
279
|
+
safe_cell(entry.hash),
|
|
280
|
+
safe_cell(entry.chat_id),
|
|
281
|
+
]
|
|
282
|
+
for entry in sorted(entries, key=lambda item: item.chat_name.casefold())
|
|
283
|
+
]
|
|
284
|
+
widths = [len(header) for header in CHAT_HEADERS]
|
|
285
|
+
for row in rows:
|
|
286
|
+
for index, cell in enumerate(row):
|
|
287
|
+
widths[index] = max(widths[index], len(cell))
|
|
288
|
+
|
|
289
|
+
print(
|
|
290
|
+
" ".join(header.ljust(widths[index]) for index, header in enumerate(CHAT_HEADERS)).rstrip()
|
|
291
|
+
)
|
|
292
|
+
for row in rows:
|
|
293
|
+
print(" ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)).rstrip())
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _has_selectors(args) -> bool:
|
|
297
|
+
return bool(args.name or args.number or args.chat or args.jid)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _selected_chats(args, entries: list[ChatEntry], codec: ArchiveCodec) -> list[ChatEntry]:
|
|
301
|
+
if _has_selectors(args):
|
|
302
|
+
selected = select_chats(entries, args.name, args.number, args.chat, args.jid, codec)
|
|
303
|
+
if not selected:
|
|
304
|
+
raise NoMatchesError("no chats matched the selectors; try `waidrop chats --name '*'`")
|
|
305
|
+
return selected
|
|
306
|
+
if not entries:
|
|
307
|
+
raise NoMatchesError("chat directory is empty (has anything been uploaded yet?)")
|
|
308
|
+
return entries
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _run_chats(args) -> int:
|
|
312
|
+
cfg, client, codec = _open_archive(args)
|
|
313
|
+
entries = load_directory(client, cfg, codec)
|
|
314
|
+
selected = select_chats(entries, args.name, args.number, args.chat, args.jid, codec)
|
|
315
|
+
|
|
316
|
+
if not entries:
|
|
317
|
+
raise NoMatchesError("chat directory is empty (has anything been uploaded yet?)")
|
|
318
|
+
if _has_selectors(args) and not selected:
|
|
319
|
+
raise NoMatchesError("no chats matched the selectors; try --name '*' to list everything")
|
|
320
|
+
|
|
321
|
+
_print_chats(selected if selected else entries)
|
|
322
|
+
return 0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _run_query(args) -> int:
|
|
326
|
+
cfg, client, codec = _open_archive(args)
|
|
327
|
+
window = resolve_window(args.from_text, args.to_text, args.last_text, cfg.tz)
|
|
328
|
+
entries = load_directory(client, cfg, codec)
|
|
329
|
+
selected = _selected_chats(args, entries, codec)
|
|
330
|
+
|
|
331
|
+
if not args.quiet:
|
|
332
|
+
print(f"waidrop: window {window.label}", file=sys.stderr)
|
|
333
|
+
if _has_selectors(args):
|
|
334
|
+
print(f"waidrop: {len(selected)} chat(s) matched", file=sys.stderr)
|
|
335
|
+
else:
|
|
336
|
+
print(f"waidrop: all chats ({len(selected)})", file=sys.stderr)
|
|
337
|
+
|
|
338
|
+
tasks = plan_tasks(client, cfg, codec, selected, window)
|
|
339
|
+
if not args.quiet:
|
|
340
|
+
print(f"waidrop: {len(tasks)} part object(s) in range", file=sys.stderr)
|
|
341
|
+
|
|
342
|
+
records = collect(client, cfg, codec, tasks, window)
|
|
343
|
+
if not records:
|
|
344
|
+
raise NoMatchesError("no archived messages found for the matched chats in this window")
|
|
345
|
+
|
|
346
|
+
lines = render(records, args.format, cfg.tz)
|
|
347
|
+
write_lines(lines, args.out)
|
|
348
|
+
|
|
349
|
+
if args.media_out:
|
|
350
|
+
if args.media_max_bytes < 1:
|
|
351
|
+
raise UsageError("--media-max-bytes must be positive")
|
|
352
|
+
entries = export_media(
|
|
353
|
+
client,
|
|
354
|
+
cfg,
|
|
355
|
+
codec,
|
|
356
|
+
records,
|
|
357
|
+
args.media_out,
|
|
358
|
+
max_bytes=args.media_max_bytes,
|
|
359
|
+
fail_fast=args.media_fail_fast,
|
|
360
|
+
)
|
|
361
|
+
manifest = args.media_manifest or str(Path(args.media_out) / "manifest.ndjson")
|
|
362
|
+
write_manifest(manifest, entries)
|
|
363
|
+
exported = sum(1 for entry in entries if entry["status"] == "exported")
|
|
364
|
+
failed = sum(1 for entry in entries if entry["status"] == "failed")
|
|
365
|
+
skipped = sum(1 for entry in entries if entry["status"] == "skipped")
|
|
366
|
+
if not args.quiet:
|
|
367
|
+
print(
|
|
368
|
+
f"waidrop: media {exported} exported, {skipped} skipped, {failed} failed "
|
|
369
|
+
f"(manifest: {manifest})",
|
|
370
|
+
file=sys.stderr,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if not args.quiet:
|
|
374
|
+
print(f"waidrop: {len(records)} record(s) written", file=sys.stderr)
|
|
375
|
+
return 0
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _run_context(args) -> int:
|
|
379
|
+
cfg, client, codec = _open_archive(args)
|
|
380
|
+
analysis = resolve_window(args.from_text, args.to_text, args.last_text, cfg.tz)
|
|
381
|
+
context_before = parse_context_before(args.context_before)
|
|
382
|
+
context_ms = int(context_before.total_seconds() * 1000)
|
|
383
|
+
if args.max_chars < 0:
|
|
384
|
+
raise UsageError("--max-chars must be zero or positive")
|
|
385
|
+
if args.media_out and args.media_max_bytes < 1:
|
|
386
|
+
raise UsageError("--media-max-bytes must be positive")
|
|
387
|
+
|
|
388
|
+
if context_ms > 0:
|
|
389
|
+
fetch_start = analysis.start - context_before
|
|
390
|
+
fetch_window = Window(
|
|
391
|
+
start_ms=int(fetch_start.timestamp() * 1000),
|
|
392
|
+
end_ms=analysis.end_ms,
|
|
393
|
+
start=fetch_start,
|
|
394
|
+
end=analysis.end,
|
|
395
|
+
label=f"context before {args.context_before} + {analysis.label}",
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
fetch_window = analysis
|
|
399
|
+
|
|
400
|
+
entries = load_directory(client, cfg, codec)
|
|
401
|
+
selected = _selected_chats(args, entries, codec)
|
|
402
|
+
|
|
403
|
+
if not args.quiet:
|
|
404
|
+
print(f"waidrop: analysis window {analysis.label}", file=sys.stderr)
|
|
405
|
+
if context_ms > 0:
|
|
406
|
+
print(f"waidrop: fetching context before {args.context_before}", file=sys.stderr)
|
|
407
|
+
if _has_selectors(args):
|
|
408
|
+
print(f"waidrop: {len(selected)} chat(s) matched", file=sys.stderr)
|
|
409
|
+
else:
|
|
410
|
+
print(f"waidrop: all chats ({len(selected)})", file=sys.stderr)
|
|
411
|
+
|
|
412
|
+
tasks = plan_tasks(client, cfg, codec, selected, fetch_window)
|
|
413
|
+
if not args.quiet:
|
|
414
|
+
print(f"waidrop: {len(tasks)} part object(s) in range", file=sys.stderr)
|
|
415
|
+
|
|
416
|
+
records = collect(client, cfg, codec, tasks, fetch_window)
|
|
417
|
+
threads = group_records(records, selected, analysis)
|
|
418
|
+
if not threads:
|
|
419
|
+
raise NoMatchesError(
|
|
420
|
+
"no archived messages found in the analysis window (context-only records ignored)"
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if args.media_out:
|
|
424
|
+
media_dir = Path(args.media_out).expanduser().resolve()
|
|
425
|
+
media_records = [
|
|
426
|
+
message for thread in threads for message in thread.messages if is_media_record(message)
|
|
427
|
+
]
|
|
428
|
+
media_entries = export_media(
|
|
429
|
+
client,
|
|
430
|
+
cfg,
|
|
431
|
+
codec,
|
|
432
|
+
media_records,
|
|
433
|
+
media_dir,
|
|
434
|
+
max_bytes=args.media_max_bytes,
|
|
435
|
+
fail_fast=args.media_fail_fast,
|
|
436
|
+
)
|
|
437
|
+
attach_media_exports(threads, media_entries)
|
|
438
|
+
manifest = args.media_manifest or str(media_dir / "manifest.ndjson")
|
|
439
|
+
write_manifest(manifest, media_entries)
|
|
440
|
+
if not args.quiet:
|
|
441
|
+
exported = sum(1 for entry in media_entries if entry["status"] == "exported")
|
|
442
|
+
skipped = sum(1 for entry in media_entries if entry["status"] == "skipped")
|
|
443
|
+
failed = sum(1 for entry in media_entries if entry["status"] == "failed")
|
|
444
|
+
print(
|
|
445
|
+
f"waidrop: media {exported} exported, {skipped} skipped, {failed} failed "
|
|
446
|
+
f"(manifest: {manifest})",
|
|
447
|
+
file=sys.stderr,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
chunks = build_chunks(threads, analysis, context_ms, args.max_chars, cfg.tz)
|
|
451
|
+
lines = (
|
|
452
|
+
render_chunks_ndjson(chunks)
|
|
453
|
+
if args.format == "ndjson"
|
|
454
|
+
else render_chunks_text(chunks, cfg.tz)
|
|
455
|
+
)
|
|
456
|
+
write_lines(lines, args.out)
|
|
457
|
+
|
|
458
|
+
if not args.quiet:
|
|
459
|
+
in_window = sum(
|
|
460
|
+
1 for thread in threads for message in thread.messages if message["inWindow"]
|
|
461
|
+
)
|
|
462
|
+
context = sum(
|
|
463
|
+
1 for thread in threads for message in thread.messages if not message["inWindow"]
|
|
464
|
+
)
|
|
465
|
+
print(
|
|
466
|
+
f"waidrop: {in_window} message(s) in window, {context} context message(s)",
|
|
467
|
+
file=sys.stderr,
|
|
468
|
+
)
|
|
469
|
+
print(f"waidrop: {len(chunks)} chunk(s) written", file=sys.stderr)
|
|
470
|
+
return 0
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _auth_config_for_client(auth: AuthConfig) -> DropboxConfig:
|
|
474
|
+
"""Build a client config for account/status/revoke (no archive key needed)."""
|
|
475
|
+
return DropboxConfig(
|
|
476
|
+
token=auth.token,
|
|
477
|
+
refresh_token=auth.refresh_token,
|
|
478
|
+
app_key=auth.app_key,
|
|
479
|
+
app_secret=auth.app_secret,
|
|
480
|
+
archive_key="",
|
|
481
|
+
account="primary",
|
|
482
|
+
folder="",
|
|
483
|
+
tz=datetime.now().astimezone().tzinfo,
|
|
484
|
+
timeout=auth.timeout,
|
|
485
|
+
max_parts=1000,
|
|
486
|
+
api_base=auth.api_base,
|
|
487
|
+
content_base=auth.content_base,
|
|
488
|
+
token_url=auth.token_url,
|
|
489
|
+
revoke_url=auth.revoke_url,
|
|
490
|
+
credentials_path=auth.credentials_path,
|
|
491
|
+
from_file=auth.from_file,
|
|
492
|
+
token_expires_at=auth.token_expires_at,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _run_auth(args) -> int:
|
|
497
|
+
if args.revoke:
|
|
498
|
+
return _auth_revoke(args)
|
|
499
|
+
if args.status:
|
|
500
|
+
return _auth_status(args)
|
|
501
|
+
return _auth_login(args)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _auth_login(args) -> int:
|
|
505
|
+
auth = resolve_auth(args)
|
|
506
|
+
if not auth.app_key:
|
|
507
|
+
raise UsageError(
|
|
508
|
+
"no Dropbox app key configured; set WAIDROP_DROPBOX_APP_KEY or --dropbox-app-key"
|
|
509
|
+
)
|
|
510
|
+
if args.dropbox_app_secret:
|
|
511
|
+
print(
|
|
512
|
+
"waidrop: warning: --dropbox-app-secret is not needed for the built-in app and is never stored",
|
|
513
|
+
file=sys.stderr,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
open_browser = not args.no_browser
|
|
517
|
+
if args.loopback:
|
|
518
|
+
parsed = run_loopback_flow(
|
|
519
|
+
app_key=auth.app_key,
|
|
520
|
+
token_url=auth.token_url,
|
|
521
|
+
port=args.port,
|
|
522
|
+
client_secret=auth.app_secret or None,
|
|
523
|
+
open_browser=open_browser,
|
|
524
|
+
timeout=max(auth.timeout * 5, 120.0),
|
|
525
|
+
print_fn=lambda message: print(message, file=sys.stderr),
|
|
526
|
+
)
|
|
527
|
+
else:
|
|
528
|
+
parsed = run_paste_flow(
|
|
529
|
+
app_key=auth.app_key,
|
|
530
|
+
token_url=auth.token_url,
|
|
531
|
+
client_secret=auth.app_secret or None,
|
|
532
|
+
open_browser=open_browser,
|
|
533
|
+
code=args.code,
|
|
534
|
+
print_fn=lambda message: print(message, file=sys.stderr),
|
|
535
|
+
timeout=auth.timeout,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
missing = missing_scopes(parsed["scope"])
|
|
539
|
+
if missing:
|
|
540
|
+
raise UsageError(
|
|
541
|
+
f"Dropbox authorized but missing required scopes: {', '.join(missing)}. "
|
|
542
|
+
"Enable them in the App Console and run `waidrop auth` again."
|
|
543
|
+
)
|
|
544
|
+
if not parsed.get("refresh_token"):
|
|
545
|
+
raise RemoteError(
|
|
546
|
+
"Dropbox did not return a refresh token; the app must allow offline access (token_access_type=offline)"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
creds = Credentials(
|
|
550
|
+
app_key=auth.app_key,
|
|
551
|
+
refresh_token=parsed["refresh_token"],
|
|
552
|
+
access_token=parsed["access_token"],
|
|
553
|
+
expires_at=parsed["expires_at"],
|
|
554
|
+
scope=parsed["scope"],
|
|
555
|
+
account_id=parsed.get("account_id"),
|
|
556
|
+
obtained_at=datetime.now().astimezone().isoformat(),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
client = DropboxClient(_auth_config_for_client(auth))
|
|
561
|
+
account = client.get_current_account()
|
|
562
|
+
name = account.get("name") or {}
|
|
563
|
+
creds.account_name = name.get("display_name") or name.get("given_name")
|
|
564
|
+
creds.email = account.get("email")
|
|
565
|
+
except RemoteError:
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
save_credentials(auth.credentials_path, creds)
|
|
569
|
+
who = creds.account_name or creds.email or creds.account_id or "Dropbox"
|
|
570
|
+
print(f"waidrop: connected as {who}")
|
|
571
|
+
print(f"waidrop: credentials stored at {auth.credentials_path}")
|
|
572
|
+
return 0
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _auth_status(args) -> int:
|
|
576
|
+
auth = resolve_auth(args)
|
|
577
|
+
if not (auth.token or (auth.refresh_token and auth.app_key)):
|
|
578
|
+
raise UsageError("Dropbox is not connected. Run `waidrop auth`.")
|
|
579
|
+
|
|
580
|
+
client = DropboxClient(_auth_config_for_client(auth))
|
|
581
|
+
account = client.get_current_account()
|
|
582
|
+
name = account.get("name") or {}
|
|
583
|
+
account_name = name.get("display_name") or name.get("given_name")
|
|
584
|
+
|
|
585
|
+
creds = load_credentials(auth.credentials_path) if auth.from_file else None
|
|
586
|
+
scopes = (creds.scope if creds else None) or []
|
|
587
|
+
missing = missing_scopes(scopes) if scopes else []
|
|
588
|
+
|
|
589
|
+
if args.as_json:
|
|
590
|
+
payload = {
|
|
591
|
+
"ok": not missing,
|
|
592
|
+
"appKey": auth.app_key,
|
|
593
|
+
"account": {
|
|
594
|
+
"name": account_name,
|
|
595
|
+
"email": account.get("email"),
|
|
596
|
+
"accountId": account.get("account_id"),
|
|
597
|
+
},
|
|
598
|
+
"scopes": scopes,
|
|
599
|
+
"missingScopes": missing,
|
|
600
|
+
"credentialsPath": auth.credentials_path if creds else None,
|
|
601
|
+
}
|
|
602
|
+
print(json.dumps(payload, indent=2))
|
|
603
|
+
else:
|
|
604
|
+
who = account_name or account.get("email") or account.get("account_id") or "Dropbox"
|
|
605
|
+
print(f"Dropbox: connected as {who}")
|
|
606
|
+
if scopes:
|
|
607
|
+
print(f"Scopes: {' '.join(scopes)}")
|
|
608
|
+
if missing:
|
|
609
|
+
print(f"Missing required scopes: {' '.join(missing)}", file=sys.stderr)
|
|
610
|
+
if creds:
|
|
611
|
+
print(f"Credentials: {auth.credentials_path}")
|
|
612
|
+
return 0 if not missing else 2
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _auth_revoke(args) -> int:
|
|
616
|
+
auth = resolve_auth(args)
|
|
617
|
+
attempted = bool(auth.token or (auth.refresh_token and auth.app_key))
|
|
618
|
+
revoked = False
|
|
619
|
+
revoke_error: RemoteError | None = None
|
|
620
|
+
if attempted:
|
|
621
|
+
try:
|
|
622
|
+
client = DropboxClient(_auth_config_for_client(auth))
|
|
623
|
+
revoked = client.revoke()
|
|
624
|
+
except RemoteError as exc:
|
|
625
|
+
revoke_error = exc
|
|
626
|
+
print(f"waidrop: {exc}", file=sys.stderr)
|
|
627
|
+
|
|
628
|
+
if revoke_error is not None:
|
|
629
|
+
print(
|
|
630
|
+
"waidrop: could not revoke Dropbox access; local credentials were kept",
|
|
631
|
+
file=sys.stderr,
|
|
632
|
+
)
|
|
633
|
+
return revoke_error.exit_code
|
|
634
|
+
|
|
635
|
+
deleted = delete_credentials(auth.credentials_path)
|
|
636
|
+
if revoked:
|
|
637
|
+
print("waidrop: revoked Dropbox access and removed local credentials")
|
|
638
|
+
elif deleted:
|
|
639
|
+
print(
|
|
640
|
+
"waidrop: token already invalid; removed local credentials "
|
|
641
|
+
"(revoke in Dropbox settings if needed)",
|
|
642
|
+
file=sys.stderr,
|
|
643
|
+
)
|
|
644
|
+
else:
|
|
645
|
+
print("waidrop: no Dropbox credentials to revoke", file=sys.stderr)
|
|
646
|
+
return 0
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _run_config(args) -> int:
|
|
650
|
+
env_path = Path(args.env_file).expanduser() if args.env_file else default_env_path()
|
|
651
|
+
existing = read_env_file_unchecked(env_path)
|
|
652
|
+
|
|
653
|
+
key = str(args.key or os.environ.get("WAIDROP_KEY") or "").strip()
|
|
654
|
+
if not key:
|
|
655
|
+
existing_key = existing.get("WAIDROP_KEY", "").strip()
|
|
656
|
+
if existing_key and not is_valid_key(existing_key):
|
|
657
|
+
existing_key = ""
|
|
658
|
+
if not sys.stdin.isatty():
|
|
659
|
+
if not existing_key:
|
|
660
|
+
raise UsageError(
|
|
661
|
+
"archive key is required; pass --key, set WAIDROP_KEY, or run interactively"
|
|
662
|
+
)
|
|
663
|
+
key = existing_key
|
|
664
|
+
else:
|
|
665
|
+
prompt = "Archive key (base64, 32 bytes)"
|
|
666
|
+
if existing_key:
|
|
667
|
+
prompt += " [keep existing]"
|
|
668
|
+
prompt += ": "
|
|
669
|
+
try:
|
|
670
|
+
key = getpass.getpass(prompt).strip()
|
|
671
|
+
except (EOFError, KeyboardInterrupt) as exc:
|
|
672
|
+
raise UsageError("archive key prompt aborted") from exc
|
|
673
|
+
if not key:
|
|
674
|
+
if not existing_key:
|
|
675
|
+
raise UsageError("archive key is required")
|
|
676
|
+
key = existing_key
|
|
677
|
+
if not is_valid_key(key):
|
|
678
|
+
raise UsageError("encryption key must be 32 bytes encoded as base64")
|
|
679
|
+
|
|
680
|
+
account = (
|
|
681
|
+
str(args.account or "").strip() or str(os.environ.get("WAIDROP_ACCOUNT") or "").strip()
|
|
682
|
+
)
|
|
683
|
+
if not account:
|
|
684
|
+
default_account = existing.get("WAIDROP_ACCOUNT", "").strip() or DEFAULT_ACCOUNT
|
|
685
|
+
if sys.stdin.isatty():
|
|
686
|
+
try:
|
|
687
|
+
account = input(f"Account alias [{default_account}]: ").strip() or default_account
|
|
688
|
+
except (EOFError, KeyboardInterrupt) as exc:
|
|
689
|
+
raise UsageError("account alias prompt aborted") from exc
|
|
690
|
+
else:
|
|
691
|
+
account = default_account
|
|
692
|
+
if not ACCOUNT_RE.match(account):
|
|
693
|
+
raise UsageError("account alias may only contain letters, digits and . _ -")
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
write_env_file(env_path, key=key, account=account)
|
|
697
|
+
except OSError as exc:
|
|
698
|
+
raise UsageError(f"could not write {env_path}: {exc}") from exc
|
|
699
|
+
|
|
700
|
+
mode = "" if os.name == "nt" else " (0600)"
|
|
701
|
+
print(f"waidrop: wrote {env_path}{mode}")
|
|
702
|
+
print(f"waidrop: account {account}")
|
|
703
|
+
return 0
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _attach_env_file(args) -> None:
|
|
707
|
+
"""Attach CLI env file values (flags and the shell environment still win)."""
|
|
708
|
+
args.env_file_values = load_env_file(default_env_path(args))
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def main(argv: list[str] | None = None) -> int:
|
|
712
|
+
parser = build_parser()
|
|
713
|
+
args = parser.parse_args(argv)
|
|
714
|
+
|
|
715
|
+
try:
|
|
716
|
+
if args.command in {"chats", "query", "context"}:
|
|
717
|
+
_attach_env_file(args)
|
|
718
|
+
if args.command == "auth":
|
|
719
|
+
return _run_auth(args)
|
|
720
|
+
if args.command == "config":
|
|
721
|
+
return _run_config(args)
|
|
722
|
+
if args.command == "chats":
|
|
723
|
+
return _run_chats(args)
|
|
724
|
+
if args.command == "context":
|
|
725
|
+
return _run_context(args)
|
|
726
|
+
return _run_query(args)
|
|
727
|
+
except CliError as exc:
|
|
728
|
+
print(f"waidrop: {exc}", file=sys.stderr)
|
|
729
|
+
return exc.exit_code
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
if __name__ == "__main__":
|
|
733
|
+
raise SystemExit(main())
|