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