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/chats.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Chat directory loading (decrypted) and selector matching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from cryptography.exceptions import InvalidTag
|
|
11
|
+
|
|
12
|
+
from .archive import ArchiveCodec
|
|
13
|
+
from .config import DropboxConfig
|
|
14
|
+
from .errors import RemoteError, UsageError
|
|
15
|
+
|
|
16
|
+
PHONE_SERVERS = {"c.us", "s.whatsapp.net"}
|
|
17
|
+
WILDCARD_CHARS = set("*?[")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ChatEntry:
|
|
22
|
+
hash: str
|
|
23
|
+
chat_id: str
|
|
24
|
+
chat_name: str
|
|
25
|
+
chat_phone: str | None = None
|
|
26
|
+
chat_pushname: str | None = None
|
|
27
|
+
is_group: bool = False
|
|
28
|
+
first_seen: int | None = None
|
|
29
|
+
last_seen: int | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _jid_phone(chat_id: str) -> str | None:
|
|
33
|
+
if not isinstance(chat_id, str) or "@" not in chat_id:
|
|
34
|
+
return None
|
|
35
|
+
user, server = chat_id.rsplit("@", 1)
|
|
36
|
+
if server not in PHONE_SERVERS:
|
|
37
|
+
return None
|
|
38
|
+
return user if user.isdigit() else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _entry_from_document(document: dict, fallback_hash: str | None = None) -> ChatEntry | None:
|
|
42
|
+
if not isinstance(document, dict):
|
|
43
|
+
return None
|
|
44
|
+
chat_id = document.get("chatId")
|
|
45
|
+
if not isinstance(chat_id, str) or not chat_id:
|
|
46
|
+
return None
|
|
47
|
+
entry_hash = document.get("hash") or fallback_hash
|
|
48
|
+
if not isinstance(entry_hash, str) or not entry_hash:
|
|
49
|
+
entry_hash = ""
|
|
50
|
+
phone = document.get("chatPhone") or _jid_phone(chat_id)
|
|
51
|
+
return ChatEntry(
|
|
52
|
+
hash=entry_hash,
|
|
53
|
+
chat_id=chat_id,
|
|
54
|
+
chat_name=str(document.get("chatName") or "unknown"),
|
|
55
|
+
chat_phone=str(phone) if phone else None,
|
|
56
|
+
chat_pushname=document.get("chatPushname") or None,
|
|
57
|
+
is_group=bool(document.get("isGroup")),
|
|
58
|
+
first_seen=document.get("firstSeen"),
|
|
59
|
+
last_seen=document.get("lastSeen"),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_directory(client, cfg: DropboxConfig, codec: ArchiveCodec) -> list[ChatEntry]:
|
|
64
|
+
index_path = f"{cfg.account_root}/_chats/index{codec.chat_suffix}"
|
|
65
|
+
payload = client.download(index_path)
|
|
66
|
+
if payload is not None:
|
|
67
|
+
try:
|
|
68
|
+
document = json.loads(codec.decode_text(payload))
|
|
69
|
+
except (ValueError, InvalidTag, json.JSONDecodeError) as exc:
|
|
70
|
+
raise RemoteError(f"chat index could not be decrypted: {exc}") from exc
|
|
71
|
+
if not isinstance(document, dict):
|
|
72
|
+
raise RemoteError("chat index has an invalid format")
|
|
73
|
+
entries = [_entry_from_document(item) for item in document.get("chats", [])]
|
|
74
|
+
return [entry for entry in entries if entry]
|
|
75
|
+
|
|
76
|
+
entries: list[ChatEntry] = []
|
|
77
|
+
for item in client.list_folder(f"{cfg.account_root}/_chats"):
|
|
78
|
+
if item.get(".tag") != "file":
|
|
79
|
+
continue
|
|
80
|
+
name = item.get("name") or ""
|
|
81
|
+
if not name.endswith(codec.chat_suffix) or name == f"index{codec.chat_suffix}":
|
|
82
|
+
continue
|
|
83
|
+
data = client.download(item.get("path_display") or item.get("path_lower") or "")
|
|
84
|
+
if data is None:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
document = json.loads(codec.decode_text(data))
|
|
88
|
+
except (ValueError, InvalidTag, json.JSONDecodeError):
|
|
89
|
+
continue
|
|
90
|
+
if not isinstance(document, dict):
|
|
91
|
+
continue
|
|
92
|
+
entry = _entry_from_document(document, fallback_hash=name[: -len(codec.chat_suffix)])
|
|
93
|
+
if entry:
|
|
94
|
+
entries.append(entry)
|
|
95
|
+
return entries
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _normalize_phone(value: str) -> str:
|
|
99
|
+
digits = re.sub(r"\D", "", str(value or ""))
|
|
100
|
+
if len(digits) < 5:
|
|
101
|
+
raise UsageError(f"not a phone number: {value!r}")
|
|
102
|
+
return digits
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _phone_digits(value: object) -> str | None:
|
|
106
|
+
digits = re.sub(r"\D", "", str(value or ""))
|
|
107
|
+
return digits if len(digits) >= 5 else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _name_matcher(pattern: str):
|
|
111
|
+
normalized = str(pattern or "").strip().casefold()
|
|
112
|
+
if not normalized:
|
|
113
|
+
return lambda value: False
|
|
114
|
+
if WILDCARD_CHARS.intersection(normalized):
|
|
115
|
+
return lambda value: fnmatch.fnmatchcase(value.casefold(), normalized)
|
|
116
|
+
return lambda value: normalized in value.casefold()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def select_chats(
|
|
120
|
+
entries: list[ChatEntry],
|
|
121
|
+
names: list[str],
|
|
122
|
+
numbers: list[str],
|
|
123
|
+
hashes: list[str],
|
|
124
|
+
jids: list[str],
|
|
125
|
+
codec: ArchiveCodec,
|
|
126
|
+
) -> list[ChatEntry]:
|
|
127
|
+
wanted_numbers = {_normalize_phone(number) for number in numbers}
|
|
128
|
+
wanted_hashes = {value.strip().lower() for value in hashes if value.strip()}
|
|
129
|
+
wanted_jids = {value.strip() for value in jids if value.strip()}
|
|
130
|
+
matchers = [_name_matcher(name) for name in names if str(name).strip()]
|
|
131
|
+
|
|
132
|
+
selected: list[ChatEntry] = []
|
|
133
|
+
seen: set[str] = set()
|
|
134
|
+
|
|
135
|
+
def add(entry: ChatEntry) -> None:
|
|
136
|
+
key = entry.hash or entry.chat_id or f"name:{entry.chat_name.casefold()}"
|
|
137
|
+
if key not in seen:
|
|
138
|
+
seen.add(key)
|
|
139
|
+
selected.append(entry)
|
|
140
|
+
|
|
141
|
+
for entry in entries:
|
|
142
|
+
if entry.hash.lower() in wanted_hashes or entry.chat_id in wanted_jids:
|
|
143
|
+
add(entry)
|
|
144
|
+
continue
|
|
145
|
+
if wanted_numbers and (
|
|
146
|
+
(_phone_digits(entry.chat_phone) in wanted_numbers)
|
|
147
|
+
or (_jid_phone(entry.chat_id) in wanted_numbers)
|
|
148
|
+
):
|
|
149
|
+
add(entry)
|
|
150
|
+
continue
|
|
151
|
+
if matchers and any(
|
|
152
|
+
matcher(entry.chat_name) or (entry.chat_pushname and matcher(entry.chat_pushname))
|
|
153
|
+
for matcher in matchers
|
|
154
|
+
):
|
|
155
|
+
add(entry)
|
|
156
|
+
|
|
157
|
+
for jid in wanted_jids:
|
|
158
|
+
if not any(entry.chat_id == jid for entry in selected):
|
|
159
|
+
add(
|
|
160
|
+
ChatEntry(
|
|
161
|
+
hash=codec.chat_hash(jid),
|
|
162
|
+
chat_id=jid,
|
|
163
|
+
chat_name="unknown",
|
|
164
|
+
chat_phone=_jid_phone(jid),
|
|
165
|
+
is_group=jid.endswith("@g.us"),
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return selected
|