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/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