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/fetch.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Range planning, Dropbox downloads, decoding, filtering and dedupe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import date, datetime, timedelta
|
|
10
|
+
|
|
11
|
+
from cryptography.exceptions import InvalidTag
|
|
12
|
+
|
|
13
|
+
from .archive import ArchiveCodec
|
|
14
|
+
from .chats import ChatEntry
|
|
15
|
+
from .config import DropboxConfig, Window
|
|
16
|
+
from .errors import GuardError, NoMatchesError, RemoteError
|
|
17
|
+
|
|
18
|
+
MAX_WORKERS = 4
|
|
19
|
+
PART_RE = re.compile(r"^part-(\d+)(?:-(\d+))?-(\d+)\.(enc|ndjson)$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class FetchTask:
|
|
24
|
+
chat_hash: str
|
|
25
|
+
key: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def effective_ts(record: dict):
|
|
29
|
+
value = record.get("ts")
|
|
30
|
+
if value is None:
|
|
31
|
+
value = record.get("sortTs")
|
|
32
|
+
if value is None:
|
|
33
|
+
value = record.get("capturedAt")
|
|
34
|
+
return value if isinstance(value, (int, float)) else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _media_stored(record: dict) -> bool:
|
|
38
|
+
media = record.get("media")
|
|
39
|
+
return isinstance(media, dict) and media.get("status") == "stored"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _captured_at(record: dict) -> float:
|
|
43
|
+
value = record.get("capturedAt")
|
|
44
|
+
return value if isinstance(value, (int, float)) else 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _prefer(candidate: dict, current: dict) -> bool:
|
|
48
|
+
candidate_store = candidate.get("source") == "store"
|
|
49
|
+
current_store = current.get("source") == "store"
|
|
50
|
+
if candidate_store != current_store:
|
|
51
|
+
return candidate_store
|
|
52
|
+
if _media_stored(candidate) != _media_stored(current):
|
|
53
|
+
return _media_stored(candidate)
|
|
54
|
+
return _captured_at(candidate) > _captured_at(current)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _dt_from_folder(name: str) -> date | None:
|
|
58
|
+
if not name.startswith("dt="):
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
return date.fromisoformat(name[3:])
|
|
62
|
+
except ValueError:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_part_name(name: str, suffix: str | None = None):
|
|
67
|
+
"""Return (first_ts, last_ts|None, seq) for a part object."""
|
|
68
|
+
name = name or ""
|
|
69
|
+
if suffix is not None and not name.endswith(suffix):
|
|
70
|
+
return None
|
|
71
|
+
match = PART_RE.match(name)
|
|
72
|
+
if not match:
|
|
73
|
+
return None
|
|
74
|
+
first_ts = int(match.group(1))
|
|
75
|
+
last_ts = int(match.group(2)) if match.group(2) else None
|
|
76
|
+
seq = int(match.group(3))
|
|
77
|
+
return first_ts, last_ts, seq
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _chat_could_have_window(chat: ChatEntry, window: Window) -> bool:
|
|
81
|
+
if chat.last_seen is not None and chat.last_seen < window.start_ms:
|
|
82
|
+
return False
|
|
83
|
+
if chat.first_seen is not None and chat.first_seen >= window.end_ms:
|
|
84
|
+
return False
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _select_parts(parts: list[dict], window: Window) -> list[dict]:
|
|
89
|
+
"""Choose parts that can contain records in [start, end).
|
|
90
|
+
|
|
91
|
+
New names carry lastTs (exact interval). Legacy names only carry firstTs:
|
|
92
|
+
take the last legacy part starting at/before the window, plus every later
|
|
93
|
+
part. Parts are written in chronological order, so everything before that
|
|
94
|
+
anchor ends before the window starts (modulo repaired re-uploads).
|
|
95
|
+
"""
|
|
96
|
+
ordered = sorted(parts, key=lambda part: (part["first_ts"], part["seq"]))
|
|
97
|
+
selected = [
|
|
98
|
+
part
|
|
99
|
+
for part in ordered
|
|
100
|
+
if part["last_ts"] is not None
|
|
101
|
+
and part["last_ts"] >= window.start_ms
|
|
102
|
+
and part["first_ts"] < window.end_ms
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
anchor = -1
|
|
106
|
+
for index, part in enumerate(ordered):
|
|
107
|
+
if part["last_ts"] is None and part["first_ts"] <= window.start_ms:
|
|
108
|
+
anchor = index
|
|
109
|
+
for index, part in enumerate(ordered):
|
|
110
|
+
if part["last_ts"] is not None:
|
|
111
|
+
continue
|
|
112
|
+
if part["first_ts"] >= window.end_ms:
|
|
113
|
+
continue
|
|
114
|
+
if anchor < 0 or index >= anchor:
|
|
115
|
+
selected.append(part)
|
|
116
|
+
|
|
117
|
+
seen = set()
|
|
118
|
+
unique = []
|
|
119
|
+
for part in sorted(selected, key=lambda item: (item["first_ts"], item["seq"])):
|
|
120
|
+
if part["key"] not in seen:
|
|
121
|
+
seen.add(part["key"])
|
|
122
|
+
unique.append(part)
|
|
123
|
+
return unique
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def plan_tasks(
|
|
127
|
+
client, cfg: DropboxConfig, codec: ArchiveCodec, chats: list[ChatEntry], window: Window
|
|
128
|
+
) -> list[FetchTask]:
|
|
129
|
+
low = datetime.fromtimestamp(window.start_ms / 1000, tz=cfg.tz).date() - timedelta(days=1)
|
|
130
|
+
high = datetime.fromtimestamp(window.end_ms / 1000, tz=cfg.tz).date() + timedelta(days=1)
|
|
131
|
+
|
|
132
|
+
tasks: list[FetchTask] = []
|
|
133
|
+
for chat in chats:
|
|
134
|
+
if not _chat_could_have_window(chat, window):
|
|
135
|
+
continue
|
|
136
|
+
base = f"{cfg.account_root}/chat={chat.hash}"
|
|
137
|
+
parts: list[dict] = []
|
|
138
|
+
for item in client.list_folder(base):
|
|
139
|
+
if item.get(".tag") != "folder":
|
|
140
|
+
continue
|
|
141
|
+
day = _dt_from_folder(item.get("name") or "")
|
|
142
|
+
if day is None or not (low <= day <= high):
|
|
143
|
+
continue
|
|
144
|
+
folder_path = item.get("path_display") or item.get("path_lower") or ""
|
|
145
|
+
for part in client.list_folder(folder_path):
|
|
146
|
+
if part.get(".tag") != "file":
|
|
147
|
+
continue
|
|
148
|
+
parsed = parse_part_name(part.get("name") or "", codec.part_suffix)
|
|
149
|
+
if parsed is None:
|
|
150
|
+
continue
|
|
151
|
+
path = part.get("path_display") or part.get("path_lower") or ""
|
|
152
|
+
if not path:
|
|
153
|
+
continue
|
|
154
|
+
first_ts, last_ts, seq = parsed
|
|
155
|
+
parts.append({"key": path, "first_ts": first_ts, "last_ts": last_ts, "seq": seq})
|
|
156
|
+
for part in _select_parts(parts, window):
|
|
157
|
+
tasks.append(FetchTask(chat_hash=chat.hash, key=part["key"]))
|
|
158
|
+
|
|
159
|
+
if not tasks:
|
|
160
|
+
raise NoMatchesError("no archived messages found for the matched chats in this window")
|
|
161
|
+
if len(tasks) > cfg.max_parts:
|
|
162
|
+
raise GuardError(
|
|
163
|
+
f"{len(tasks)} part objects exceed --max-parts {cfg.max_parts}; narrow the window or selectors"
|
|
164
|
+
)
|
|
165
|
+
return tasks
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse_part(text: str, key: str) -> list[dict]:
|
|
169
|
+
records: list[dict] = []
|
|
170
|
+
for line_number, line in enumerate(text.splitlines(), 1):
|
|
171
|
+
line = line.strip()
|
|
172
|
+
if not line:
|
|
173
|
+
continue
|
|
174
|
+
try:
|
|
175
|
+
record = json.loads(line)
|
|
176
|
+
except json.JSONDecodeError as exc:
|
|
177
|
+
raise RemoteError(f"invalid NDJSON in {key} line {line_number}: {exc}") from exc
|
|
178
|
+
if isinstance(record, dict) and record.get("msgId"):
|
|
179
|
+
records.append(record)
|
|
180
|
+
return records
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def collect(
|
|
184
|
+
client, cfg: DropboxConfig, codec: ArchiveCodec, tasks: list[FetchTask], window: Window
|
|
185
|
+
) -> list[dict]:
|
|
186
|
+
def fetch(task: FetchTask):
|
|
187
|
+
payload = client.download(task.key)
|
|
188
|
+
if payload is None:
|
|
189
|
+
return task.key, None
|
|
190
|
+
try:
|
|
191
|
+
return task.key, codec.decode_text(payload)
|
|
192
|
+
except (ValueError, InvalidTag) as exc:
|
|
193
|
+
raise RemoteError(f"cannot decrypt {task.key}: {exc}") from exc
|
|
194
|
+
|
|
195
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
|
196
|
+
payloads = list(pool.map(fetch, tasks))
|
|
197
|
+
|
|
198
|
+
merged: dict[str, dict] = {}
|
|
199
|
+
for key, text in payloads:
|
|
200
|
+
if text is None:
|
|
201
|
+
continue
|
|
202
|
+
for record in _parse_part(text, key):
|
|
203
|
+
ts = effective_ts(record)
|
|
204
|
+
if ts is None or not (window.start_ms <= ts < window.end_ms):
|
|
205
|
+
continue
|
|
206
|
+
msg_id = str(record["msgId"])
|
|
207
|
+
current = merged.get(msg_id)
|
|
208
|
+
if current is None or _prefer(record, current):
|
|
209
|
+
merged[msg_id] = record
|
|
210
|
+
|
|
211
|
+
return sorted(
|
|
212
|
+
merged.values(),
|
|
213
|
+
key=lambda record: (effective_ts(record) or 0, str(record.get("msgId") or "")),
|
|
214
|
+
)
|
waidrop_cli/mcp_cli.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Run the public waidrop CLI as a subprocess."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .errors import CliError, UsageError
|
|
12
|
+
from .mcp_config import ACCOUNT_RE, DEFAULT_ACCOUNT, McpSettings, load_env_file
|
|
13
|
+
|
|
14
|
+
CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
15
|
+
CHAT_HEADER = "=== CHAT "
|
|
16
|
+
CHAT_SEPARATOR = " | "
|
|
17
|
+
FAILURE_DETAIL_LIMIT = 2000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CliResult:
|
|
22
|
+
returncode: int
|
|
23
|
+
stdout: str
|
|
24
|
+
stderr: str
|
|
25
|
+
timed_out: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class LimitedText:
|
|
30
|
+
text: str
|
|
31
|
+
truncated: bool
|
|
32
|
+
included_chats: int
|
|
33
|
+
omitted_chats: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def base_command() -> list[str]:
|
|
37
|
+
return [sys.executable, "-m", "waidrop_cli"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _base_env(settings: McpSettings) -> dict[str, str]:
|
|
41
|
+
env = dict(os.environ)
|
|
42
|
+
for key, value in load_env_file(settings.env_path).items():
|
|
43
|
+
env.setdefault(key, value)
|
|
44
|
+
if settings.account:
|
|
45
|
+
env.setdefault("WAIDROP_ACCOUNT", settings.account)
|
|
46
|
+
return env
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_env(settings: McpSettings) -> dict[str, str]:
|
|
50
|
+
env = _base_env(settings)
|
|
51
|
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
52
|
+
env.setdefault("PYTHONUTF8", "1")
|
|
53
|
+
return env
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def effective_account(settings: McpSettings) -> str:
|
|
57
|
+
account = (_base_env(settings).get("WAIDROP_ACCOUNT") or "").strip() or DEFAULT_ACCOUNT
|
|
58
|
+
if not ACCOUNT_RE.match(account):
|
|
59
|
+
raise UsageError("account alias may only contain letters, digits and . _ -")
|
|
60
|
+
return account
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_cli(args: list[str], settings: McpSettings) -> CliResult:
|
|
64
|
+
command = base_command() + args
|
|
65
|
+
try:
|
|
66
|
+
completed = subprocess.run(
|
|
67
|
+
command,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
encoding="utf-8",
|
|
71
|
+
errors="replace",
|
|
72
|
+
timeout=settings.cli_timeout_seconds,
|
|
73
|
+
env=build_env(settings),
|
|
74
|
+
check=False,
|
|
75
|
+
creationflags=CREATE_NO_WINDOW,
|
|
76
|
+
)
|
|
77
|
+
except subprocess.TimeoutExpired as exc:
|
|
78
|
+
return CliResult(
|
|
79
|
+
-1,
|
|
80
|
+
_as_text(exc.stdout),
|
|
81
|
+
_as_text(exc.stderr),
|
|
82
|
+
timed_out=True,
|
|
83
|
+
)
|
|
84
|
+
except CliError as exc:
|
|
85
|
+
return CliResult(exc.exit_code, "", str(exc))
|
|
86
|
+
except OSError as exc:
|
|
87
|
+
return CliResult(-1, "", str(exc))
|
|
88
|
+
return CliResult(completed.returncode, completed.stdout, completed.stderr)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def chats_args(
|
|
92
|
+
*,
|
|
93
|
+
names: list[str] | None = None,
|
|
94
|
+
numbers: list[str] | None = None,
|
|
95
|
+
hashes: list[str] | None = None,
|
|
96
|
+
jids: list[str] | None = None,
|
|
97
|
+
) -> list[str]:
|
|
98
|
+
args = ["chats", "--quiet"]
|
|
99
|
+
for value in names or []:
|
|
100
|
+
args += ["--name", value]
|
|
101
|
+
for value in numbers or []:
|
|
102
|
+
args += ["--number", value]
|
|
103
|
+
for value in hashes or []:
|
|
104
|
+
args += ["--chat", value]
|
|
105
|
+
for value in jids or []:
|
|
106
|
+
args += ["--jid", value]
|
|
107
|
+
return args
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def context_args(
|
|
111
|
+
*,
|
|
112
|
+
last: str | None,
|
|
113
|
+
start: str | None,
|
|
114
|
+
end: str | None,
|
|
115
|
+
context_before: str,
|
|
116
|
+
max_chars_per_chat: int,
|
|
117
|
+
max_parts: int,
|
|
118
|
+
tz: str | None,
|
|
119
|
+
names: list[str] | None = None,
|
|
120
|
+
numbers: list[str] | None = None,
|
|
121
|
+
hashes: list[str] | None = None,
|
|
122
|
+
jids: list[str] | None = None,
|
|
123
|
+
) -> list[str]:
|
|
124
|
+
args = [
|
|
125
|
+
"context",
|
|
126
|
+
"--format",
|
|
127
|
+
"text",
|
|
128
|
+
"--quiet",
|
|
129
|
+
"--context-before",
|
|
130
|
+
context_before,
|
|
131
|
+
"--max-chars",
|
|
132
|
+
str(max_chars_per_chat),
|
|
133
|
+
"--max-parts",
|
|
134
|
+
str(max_parts),
|
|
135
|
+
]
|
|
136
|
+
if last:
|
|
137
|
+
args += ["--last", last]
|
|
138
|
+
elif start:
|
|
139
|
+
args += ["--from", start]
|
|
140
|
+
if end:
|
|
141
|
+
args += ["--to", end]
|
|
142
|
+
if tz:
|
|
143
|
+
args += ["--tz", tz]
|
|
144
|
+
for value in names or []:
|
|
145
|
+
args += ["--name", value]
|
|
146
|
+
for value in numbers or []:
|
|
147
|
+
args += ["--number", value]
|
|
148
|
+
for value in hashes or []:
|
|
149
|
+
args += ["--chat", value]
|
|
150
|
+
for value in jids or []:
|
|
151
|
+
args += ["--jid", value]
|
|
152
|
+
return args
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def limit_cli_text(
|
|
156
|
+
text: str,
|
|
157
|
+
*,
|
|
158
|
+
max_chats: int,
|
|
159
|
+
max_total_chars: int,
|
|
160
|
+
) -> LimitedText:
|
|
161
|
+
identities = _chat_identities(text)
|
|
162
|
+
if max_chats <= 0 and max_total_chars <= 0:
|
|
163
|
+
return LimitedText(text, False, len(identities), 0)
|
|
164
|
+
|
|
165
|
+
blocks = _split_blocks(text)
|
|
166
|
+
if not blocks:
|
|
167
|
+
if max_total_chars > 0 and len(text) > max_total_chars:
|
|
168
|
+
excerpt = _truncate_at_line(text, max_total_chars)
|
|
169
|
+
return LimitedText(
|
|
170
|
+
excerpt + "\n[waidrop-mcp: output truncated]\n",
|
|
171
|
+
True,
|
|
172
|
+
0,
|
|
173
|
+
0,
|
|
174
|
+
)
|
|
175
|
+
return LimitedText(text, False, 0, 0)
|
|
176
|
+
|
|
177
|
+
allowed = set(identities[:max_chats]) if max_chats > 0 else set(identities)
|
|
178
|
+
selected: list[str] = []
|
|
179
|
+
used = 0
|
|
180
|
+
stopped = False
|
|
181
|
+
|
|
182
|
+
for block in blocks:
|
|
183
|
+
identity = _block_identity(block)
|
|
184
|
+
if identity not in allowed:
|
|
185
|
+
continue
|
|
186
|
+
if max_total_chars > 0 and used + len(block) > max_total_chars:
|
|
187
|
+
if not selected:
|
|
188
|
+
excerpt = _truncate_at_line(block, max_total_chars)
|
|
189
|
+
selected.append(excerpt)
|
|
190
|
+
used = len(excerpt)
|
|
191
|
+
stopped = True
|
|
192
|
+
break
|
|
193
|
+
selected.append(block)
|
|
194
|
+
used += len(block)
|
|
195
|
+
|
|
196
|
+
included = {_block_identity(block) for block in selected if _block_identity(block)}
|
|
197
|
+
omitted = len([identity for identity in identities if identity not in included])
|
|
198
|
+
truncated = stopped or omitted > 0
|
|
199
|
+
result = "".join(selected)
|
|
200
|
+
|
|
201
|
+
if truncated:
|
|
202
|
+
if omitted:
|
|
203
|
+
notice = (
|
|
204
|
+
f"[waidrop-mcp: output truncated; omitted {omitted} chat(s). "
|
|
205
|
+
"Narrow the window or raise max_total_chars.]\n"
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
notice = "[waidrop-mcp: output truncated; raise max_total_chars.]\n"
|
|
209
|
+
if not result.endswith("\n"):
|
|
210
|
+
result += "\n"
|
|
211
|
+
result += notice
|
|
212
|
+
return LimitedText(result, truncated, len(included), omitted)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def describe_failure(result: CliResult, action: str, timeout_seconds: float) -> str:
|
|
216
|
+
detail = _clean_detail(result.stderr)
|
|
217
|
+
suffix = f" {detail}" if detail else ""
|
|
218
|
+
|
|
219
|
+
if result.timed_out:
|
|
220
|
+
return (
|
|
221
|
+
f"The waidrop CLI timed out after {timeout_seconds:g}s while trying to "
|
|
222
|
+
f"{action}. Narrow the window, select fewer chats, lower max_parts, "
|
|
223
|
+
f"or raise cli_timeout_seconds.{suffix}"
|
|
224
|
+
)
|
|
225
|
+
if result.returncode == 2:
|
|
226
|
+
return f"WAiDrop CLI configuration or usage error.{suffix}"
|
|
227
|
+
if result.returncode == 3:
|
|
228
|
+
return (
|
|
229
|
+
"No archived chats or messages matched. Try a longer window or a "
|
|
230
|
+
f"different chat selector.{suffix}"
|
|
231
|
+
)
|
|
232
|
+
if result.returncode == 4:
|
|
233
|
+
return (
|
|
234
|
+
"Too many archive parts would be fetched. Narrow the window or chat "
|
|
235
|
+
f"selection, or lower max_parts.{suffix}"
|
|
236
|
+
)
|
|
237
|
+
if result.returncode == 5:
|
|
238
|
+
return (
|
|
239
|
+
"Could not read the Dropbox archive. Check `waidrop auth --status` "
|
|
240
|
+
f"and the archive key.{suffix}"
|
|
241
|
+
)
|
|
242
|
+
if result.returncode == -1:
|
|
243
|
+
return f"Could not start the waidrop CLI.{suffix}"
|
|
244
|
+
return f"WAiDrop CLI failed with exit code {result.returncode}.{suffix}"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _split_blocks(text: str) -> list[str]:
|
|
248
|
+
blocks = re.split(r"(?=^=== CHAT )", text, flags=re.MULTILINE)
|
|
249
|
+
return [block for block in blocks if block.startswith(CHAT_HEADER)]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _block_identity(block: str) -> str:
|
|
253
|
+
first_line = block.splitlines()[0] if block else ""
|
|
254
|
+
header = first_line[len(CHAT_HEADER) :] if first_line.startswith(CHAT_HEADER) else first_line
|
|
255
|
+
identity = header.partition(CHAT_SEPARATOR)[0].strip()
|
|
256
|
+
return identity or header.strip()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _chat_identities(text: str) -> list[str]:
|
|
260
|
+
identities: list[str] = []
|
|
261
|
+
seen: set[str] = set()
|
|
262
|
+
for block in _split_blocks(text):
|
|
263
|
+
identity = _block_identity(block)
|
|
264
|
+
if identity not in seen:
|
|
265
|
+
seen.add(identity)
|
|
266
|
+
identities.append(identity)
|
|
267
|
+
return identities
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _truncate_at_line(text: str, limit: int) -> str:
|
|
271
|
+
if limit <= 0:
|
|
272
|
+
return ""
|
|
273
|
+
excerpt = text[:limit]
|
|
274
|
+
if len(text) > limit:
|
|
275
|
+
newline = excerpt.rfind("\n")
|
|
276
|
+
if newline > 0:
|
|
277
|
+
excerpt = excerpt[: newline + 1]
|
|
278
|
+
return excerpt
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _clean_detail(value: str) -> str:
|
|
282
|
+
detail = (value or "").strip()
|
|
283
|
+
if len(detail) > FAILURE_DETAIL_LIMIT:
|
|
284
|
+
detail = detail[:FAILURE_DETAIL_LIMIT].rstrip() + "..."
|
|
285
|
+
return detail
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _as_text(value) -> str:
|
|
289
|
+
if value is None:
|
|
290
|
+
return ""
|
|
291
|
+
if isinstance(value, bytes):
|
|
292
|
+
return value.decode("utf-8", errors="replace")
|
|
293
|
+
return str(value)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""MCP server settings, environment file loading and precedence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
from .auth import resolve_credentials_path
|
|
13
|
+
from .config import parse_context_before, parse_duration, parse_tz
|
|
14
|
+
from .env_file import load_env_file # noqa: F401 (re-exported for existing callers)
|
|
15
|
+
from .errors import CliError
|
|
16
|
+
|
|
17
|
+
DEFAULT_LAST = "6h"
|
|
18
|
+
DEFAULT_CONTEXT_BEFORE = "30m"
|
|
19
|
+
DEFAULT_MAX_CHARS_PER_CHAT = 0
|
|
20
|
+
DEFAULT_MAX_TOTAL_CHARS = 0
|
|
21
|
+
DEFAULT_MAX_CHATS = 0
|
|
22
|
+
DEFAULT_MAX_PARTS = 1000
|
|
23
|
+
DEFAULT_CLI_TIMEOUT_SECONDS = 120.0
|
|
24
|
+
DEFAULT_ACCOUNT = "primary"
|
|
25
|
+
|
|
26
|
+
ACCOUNT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
27
|
+
|
|
28
|
+
CONFIG_KEYS = {
|
|
29
|
+
"default_last",
|
|
30
|
+
"default_context_before",
|
|
31
|
+
"max_chars_per_chat",
|
|
32
|
+
"max_total_chars",
|
|
33
|
+
"max_chats",
|
|
34
|
+
"max_parts",
|
|
35
|
+
"cli_timeout_seconds",
|
|
36
|
+
"account",
|
|
37
|
+
"credentials_file",
|
|
38
|
+
"env_file",
|
|
39
|
+
"tz",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class McpSettings:
|
|
45
|
+
default_last: str = DEFAULT_LAST
|
|
46
|
+
default_context_before: str = DEFAULT_CONTEXT_BEFORE
|
|
47
|
+
max_chars_per_chat: int = DEFAULT_MAX_CHARS_PER_CHAT
|
|
48
|
+
max_total_chars: int = DEFAULT_MAX_TOTAL_CHARS
|
|
49
|
+
max_chats: int = DEFAULT_MAX_CHATS
|
|
50
|
+
max_parts: int = DEFAULT_MAX_PARTS
|
|
51
|
+
cli_timeout_seconds: float = DEFAULT_CLI_TIMEOUT_SECONDS
|
|
52
|
+
account: str = DEFAULT_ACCOUNT
|
|
53
|
+
credentials_file: str | None = None
|
|
54
|
+
env_file: str | None = None
|
|
55
|
+
tz: str | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def credentials_path(self) -> Path:
|
|
59
|
+
return resolve_credentials_path(SimpleNamespace(credentials_file=self.credentials_file))
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def env_path(self) -> Path:
|
|
63
|
+
if self.env_file:
|
|
64
|
+
return Path(self.env_file).expanduser()
|
|
65
|
+
return self.credentials_path.parent / "env"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def default_config_path() -> Path:
|
|
69
|
+
explicit = _env_text("WAIDROP_MCP_CONFIG")
|
|
70
|
+
if explicit is not None:
|
|
71
|
+
return Path(explicit).expanduser()
|
|
72
|
+
credentials = resolve_credentials_path(
|
|
73
|
+
SimpleNamespace(credentials_file=_env_text("WAIDROP_CREDENTIALS"))
|
|
74
|
+
)
|
|
75
|
+
return credentials.parent / "mcp.json"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_settings() -> McpSettings:
|
|
79
|
+
config_path = default_config_path()
|
|
80
|
+
document = _read_config(config_path)
|
|
81
|
+
values = {key: document[key] for key in CONFIG_KEYS if key in document}
|
|
82
|
+
values = _apply_env(values)
|
|
83
|
+
settings = McpSettings(**values)
|
|
84
|
+
_validate(settings)
|
|
85
|
+
return settings
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_config(path: Path) -> dict:
|
|
89
|
+
try:
|
|
90
|
+
text = path.read_text(encoding="utf-8")
|
|
91
|
+
except FileNotFoundError:
|
|
92
|
+
return {}
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise ValueError(f"could not read {path}: {exc}") from exc
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
document = json.loads(text)
|
|
98
|
+
except json.JSONDecodeError as exc:
|
|
99
|
+
raise ValueError(f"{path} is not valid JSON: {exc}") from exc
|
|
100
|
+
|
|
101
|
+
if not isinstance(document, dict):
|
|
102
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
103
|
+
if document.get("version") != 1:
|
|
104
|
+
raise ValueError(f'{path} must contain "version": 1')
|
|
105
|
+
return document
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _apply_env(values: dict) -> dict:
|
|
109
|
+
merged = dict(values)
|
|
110
|
+
overrides = {
|
|
111
|
+
"default_last": "WAIDROP_MCP_DEFAULT_LAST",
|
|
112
|
+
"default_context_before": "WAIDROP_MCP_DEFAULT_CONTEXT_BEFORE",
|
|
113
|
+
"max_chars_per_chat": "WAIDROP_MCP_MAX_CHARS",
|
|
114
|
+
"max_total_chars": "WAIDROP_MCP_MAX_TOTAL_CHARS",
|
|
115
|
+
"max_chats": "WAIDROP_MCP_MAX_CHATS",
|
|
116
|
+
"max_parts": "WAIDROP_MCP_MAX_PARTS",
|
|
117
|
+
"cli_timeout_seconds": "WAIDROP_MCP_TIMEOUT",
|
|
118
|
+
"env_file": "WAIDROP_MCP_ENV_FILE",
|
|
119
|
+
}
|
|
120
|
+
for field, env_name in overrides.items():
|
|
121
|
+
raw = _env_text(env_name)
|
|
122
|
+
if raw is None:
|
|
123
|
+
continue
|
|
124
|
+
if field in {"max_chars_per_chat", "max_total_chars", "max_chats", "max_parts"}:
|
|
125
|
+
merged[field] = _parse_int(raw, env_name)
|
|
126
|
+
elif field == "cli_timeout_seconds":
|
|
127
|
+
merged[field] = _parse_float(raw, env_name)
|
|
128
|
+
else:
|
|
129
|
+
merged[field] = raw
|
|
130
|
+
return merged
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _validate(settings: McpSettings) -> None:
|
|
134
|
+
for field in ("default_last", "default_context_before", "account"):
|
|
135
|
+
if not isinstance(getattr(settings, field), str):
|
|
136
|
+
raise ValueError(f"{field} must be a string")
|
|
137
|
+
for field in ("max_chars_per_chat", "max_total_chars", "max_chats", "max_parts"):
|
|
138
|
+
value = getattr(settings, field)
|
|
139
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
140
|
+
raise ValueError(f"{field} must be an integer")
|
|
141
|
+
if not isinstance(settings.cli_timeout_seconds, (int, float)) or isinstance(
|
|
142
|
+
settings.cli_timeout_seconds, bool
|
|
143
|
+
):
|
|
144
|
+
raise ValueError("cli_timeout_seconds must be a number")
|
|
145
|
+
if settings.max_chars_per_chat < 0:
|
|
146
|
+
raise ValueError("max_chars_per_chat must be zero or positive")
|
|
147
|
+
if settings.max_total_chars < 0:
|
|
148
|
+
raise ValueError("max_total_chars must be zero or positive")
|
|
149
|
+
if settings.max_chats < 0:
|
|
150
|
+
raise ValueError("max_chats must be zero or positive")
|
|
151
|
+
if not 1 <= settings.max_parts <= 1000:
|
|
152
|
+
raise ValueError("max_parts must be between 1 and 1000")
|
|
153
|
+
if not 1 <= settings.cli_timeout_seconds <= 600:
|
|
154
|
+
raise ValueError("cli_timeout_seconds must be between 1 and 600")
|
|
155
|
+
if not ACCOUNT_RE.match(settings.account or ""):
|
|
156
|
+
raise ValueError("account alias may only contain letters, digits and . _ -")
|
|
157
|
+
try:
|
|
158
|
+
parse_duration(settings.default_last)
|
|
159
|
+
except CliError as exc:
|
|
160
|
+
raise ValueError(f"invalid default_last: {exc}") from exc
|
|
161
|
+
try:
|
|
162
|
+
parse_context_before(settings.default_context_before)
|
|
163
|
+
except CliError as exc:
|
|
164
|
+
raise ValueError(f"invalid default_context_before: {exc}") from exc
|
|
165
|
+
if settings.tz:
|
|
166
|
+
try:
|
|
167
|
+
parse_tz(settings.tz)
|
|
168
|
+
except CliError as exc:
|
|
169
|
+
raise ValueError(f"invalid tz: {exc}") from exc
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _env_text(name: str) -> str | None:
|
|
173
|
+
value = os.environ.get(name)
|
|
174
|
+
if value is None:
|
|
175
|
+
return None
|
|
176
|
+
value = value.strip()
|
|
177
|
+
return value or None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_int(raw: str, name: str) -> int:
|
|
181
|
+
try:
|
|
182
|
+
return int(raw)
|
|
183
|
+
except ValueError as exc:
|
|
184
|
+
raise ValueError(f"{name} must be an integer") from exc
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_float(raw: str, name: str) -> float:
|
|
188
|
+
try:
|
|
189
|
+
return float(raw)
|
|
190
|
+
except ValueError as exc:
|
|
191
|
+
raise ValueError(f"{name} must be a number") from exc
|