android-watcher 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.
Files changed (44) hide show
  1. android_watcher/__init__.py +10 -0
  2. android_watcher/catalog/__init__.py +32 -0
  3. android_watcher/catalog/catalog.toml +531 -0
  4. android_watcher/cli.py +161 -0
  5. android_watcher/config.py +262 -0
  6. android_watcher/detect/__init__.py +1 -0
  7. android_watcher/detect/_normalize.py +192 -0
  8. android_watcher/detect/android_sitemap.py +540 -0
  9. android_watcher/detect/base.py +14 -0
  10. android_watcher/detect/content.py +99 -0
  11. android_watcher/detect/feed.py +135 -0
  12. android_watcher/detect/sitemap.py +203 -0
  13. android_watcher/doctor.py +125 -0
  14. android_watcher/fetch.py +162 -0
  15. android_watcher/group.py +79 -0
  16. android_watcher/lock.py +32 -0
  17. android_watcher/models.py +156 -0
  18. android_watcher/notify/__init__.py +1 -0
  19. android_watcher/notify/base.py +21 -0
  20. android_watcher/notify/email.py +52 -0
  21. android_watcher/notify/html.py +114 -0
  22. android_watcher/notify/render.py +239 -0
  23. android_watcher/notify/slack.py +124 -0
  24. android_watcher/notify/telegram.py +46 -0
  25. android_watcher/rank.py +84 -0
  26. android_watcher/registry.py +38 -0
  27. android_watcher/run.py +283 -0
  28. android_watcher/schedule.py +488 -0
  29. android_watcher/seed/__init__.py +45 -0
  30. android_watcher/seed/seed.sql.gz +0 -0
  31. android_watcher/store.py +492 -0
  32. android_watcher/triage/__init__.py +1 -0
  33. android_watcher/triage/base.py +25 -0
  34. android_watcher/triage/claude_cli.py +185 -0
  35. android_watcher/triage/noop.py +24 -0
  36. android_watcher/tui/__init__.py +1 -0
  37. android_watcher/tui/app.py +163 -0
  38. android_watcher/tui/configio.py +215 -0
  39. android_watcher/tui/screens.py +927 -0
  40. android_watcher-1.0.0.dist-info/METADATA +310 -0
  41. android_watcher-1.0.0.dist-info/RECORD +44 -0
  42. android_watcher-1.0.0.dist-info/WHEEL +4 -0
  43. android_watcher-1.0.0.dist-info/entry_points.txt +2 -0
  44. android_watcher-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import fcntl
5
+ import os
6
+ from collections.abc import Iterator
7
+
8
+ from .models import AlreadyRunning # single definition lives in models.py
9
+
10
+ __all__ = ["AlreadyRunning", "run_lock"]
11
+
12
+
13
+ @contextlib.contextmanager
14
+ def run_lock(data_dir: str) -> Iterator[None]:
15
+ """Acquire an exclusive flock on ``data_dir/run.lock``.
16
+
17
+ Raises ``AlreadyRunning`` if the lock is already held by another process.
18
+ """
19
+ os.makedirs(data_dir, exist_ok=True)
20
+ lock_path = os.path.join(data_dir, "run.lock")
21
+ fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
22
+ try:
23
+ try:
24
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
25
+ except OSError as exc:
26
+ raise AlreadyRunning(f"another android-watcher run holds {lock_path}") from exc
27
+ try:
28
+ yield
29
+ finally:
30
+ fcntl.flock(fd, fcntl.LOCK_UN)
31
+ finally:
32
+ os.close(fd)
@@ -0,0 +1,156 @@
1
+ """Shared types: dataclasses, type aliases, exceptions, and constants.
2
+
3
+ Everything here is defined ONCE and imported everywhere else. Keeping the four
4
+ exceptions, ``Check``, ``SignalType``, and ``INTERVAL_DELTA`` in this one module
5
+ gives the package a single source of truth and an acyclic import graph.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime, timedelta
12
+ from typing import Literal
13
+
14
+ DetectorName = Literal["feed", "android_sitemap", "sitemap", "content"]
15
+ Verdict = Literal["substantive", "cosmetic"]
16
+ ChangeKind = Literal["new", "updated"]
17
+ # How a sitemap source treats reference docs:
18
+ # keep - no special handling
19
+ # drop - exclude any URL with a "reference" path segment
20
+ # index_only - keep only reference index/summary pages (Kotlin-preferred),
21
+ # dropping per-symbol class/function pages
22
+ ReferenceMode = Literal["keep", "drop", "index_only"]
23
+ SignalType = Literal["sitemap", "content"] # snapshots.signal_type
24
+ # android_sitemap + sitemap write "sitemap" (lastmod-confirmed-by-content);
25
+ # content detector writes "content". The feed detector writes NO snapshot
26
+ # (it dedupes per-item via seen_feed_items), so there is no "feed" signal_type.
27
+
28
+ # Shared schedule-interval mapping: defined ONCE here, imported by the catch-up
29
+ # gate (run.py), the scheduler (schedule.py), and doctor. cron => always due.
30
+ INTERVAL_DELTA = {
31
+ "hourly": timedelta(hours=1),
32
+ "daily": timedelta(days=1),
33
+ "weekly": timedelta(days=7),
34
+ }
35
+
36
+
37
+ # Shared exceptions: defined ONCE here, imported by lock/config/fetch/notify so
38
+ # there is exactly one class per error and no circular imports.
39
+ class ConfigError(ValueError):
40
+ """Raised on a malformed or contradictory configuration."""
41
+
42
+
43
+ class AlreadyRunning(RuntimeError):
44
+ """Raised when another android-watcher run already holds the run lock."""
45
+
46
+
47
+ class Disallowed(RuntimeError):
48
+ """Raised when robots.txt forbids fetching a URL."""
49
+
50
+
51
+ class NotifyError(RuntimeError):
52
+ """Raised when a Notifier fails to deliver a digest."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Source:
57
+ id: str
58
+ name: str
59
+ category: str
60
+ detector: DetectorName
61
+ url: str
62
+ enabled: bool = True
63
+ path_prefix: str = ""
64
+ feed_url: str = ""
65
+ content_selector: str = ""
66
+ default_weight: int = 0 # 0 => use category weight
67
+ # Sitemap-source filters (host-agnostic android_sitemap detector):
68
+ exclude_prefixes: tuple[str, ...] = () # drop URLs under any of these paths
69
+ require_segment: str = "" # if set, keep only URLs with a matching path segment
70
+ # Default index_only: reference docs are filtered to index/summary pages site-
71
+ # wide (a no-op on sources with no /reference pages). Override per source.
72
+ reference_mode: ReferenceMode = "index_only"
73
+
74
+
75
+ @dataclass
76
+ class Change:
77
+ source_id: str
78
+ url: str
79
+ change_kind: ChangeKind
80
+ title: str = ""
81
+ raw_diff: str = "" # short excerpt / diff text
82
+ fetched_hash: str = "" # confirmed content hash
83
+ detected_at: datetime = field(default_factory=lambda: datetime.now(UTC))
84
+ id: int | None = None # set by Store.record_change
85
+ verdict: Verdict | None = None
86
+ description: str | None = None # filled by Triager for substantive
87
+ group_key: str | None = None # model-assigned grouping slug; same slug = same group
88
+ group_summary: str | None = None # merged one-line summary for the group, or None
89
+ group_title: str | None = None # short model headline naming the group, or None
90
+
91
+
92
+ @dataclass
93
+ class DigestItem:
94
+ change: Change
95
+ score: int
96
+
97
+
98
+ @dataclass
99
+ class DigestGroup:
100
+ key: str
101
+ title: str
102
+ summary: str | None
103
+ category: str
104
+ source_id: str
105
+ change_kind: ChangeKind
106
+ members: list[Change] # newest-first; len >= 1
107
+ score: int = 0
108
+
109
+ @property
110
+ def primary_url(self) -> str:
111
+ return self.members[0].url
112
+
113
+ @property
114
+ def page_count(self) -> int:
115
+ return len(self.members)
116
+
117
+
118
+ @dataclass
119
+ class Digest:
120
+ groups: list[DigestGroup] # all groups, ranked (score DESC)
121
+ max_items: int = 10 # cap on groups shown in the on-channel message
122
+ tldr: str | None = None
123
+ ai_unavailable: str | None = None
124
+ sources_scanned: int = 0
125
+ pages_watched: int = 0
126
+ generated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
127
+
128
+ @property
129
+ def is_empty(self) -> bool:
130
+ return not self.groups
131
+
132
+ def message_groups(self) -> list[DigestGroup]:
133
+ return self.groups[: self.max_items]
134
+
135
+ def carried_groups(self) -> list[DigestGroup]:
136
+ return self.groups[self.max_items :]
137
+
138
+ def change_count(self) -> int:
139
+ return sum(g.page_count for g in self.groups)
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class Check:
144
+ name: str # health-check result; lives here so doctor.py and schedule.py
145
+ ok: bool # both import it without a circular import
146
+ detail: str
147
+
148
+
149
+ @dataclass
150
+ class FetchResult:
151
+ url: str
152
+ status: int # real HTTP status; 304 when not_modified
153
+ text: str # "" when not_modified
154
+ etag: str = ""
155
+ last_modified: str = ""
156
+ not_modified: bool = False # True on HTTP 304
@@ -0,0 +1 @@
1
+ from . import email, slack, telegram # noqa: F401
@@ -0,0 +1,21 @@
1
+ """Notifier protocol, re-export of NotifyError, and NOTIFIERS registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from android_watcher.config import Config
8
+ from android_watcher.models import Digest, NotifyError # shared exception defined in models.py
9
+ from android_watcher.registry import Registry
10
+
11
+ __all__ = ["NotifyError", "Notifier", "NOTIFIERS"]
12
+
13
+
14
+ @runtime_checkable
15
+ class Notifier(Protocol):
16
+ name: str
17
+
18
+ def send(self, digest: Digest, config: Config) -> set[int]: ...
19
+
20
+
21
+ NOTIFIERS: Registry[Notifier] = Registry("notifier")
@@ -0,0 +1,52 @@
1
+ """Email notifier: sends digest via SMTP with TLS enforced (fail closed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import smtplib
6
+ import ssl
7
+ from email.message import EmailMessage
8
+
9
+ from android_watcher.config import Config
10
+ from android_watcher.models import Digest
11
+ from android_watcher.notify.base import NOTIFIERS, NotifyError
12
+ from android_watcher.notify.render import render_email
13
+
14
+
15
+ @NOTIFIERS.register("email")
16
+ class EmailNotifier:
17
+ name = "email"
18
+
19
+ def send(self, digest: Digest, config: Config) -> set[int]:
20
+ ec = config.email
21
+ html, plaintext = render_email(digest)
22
+
23
+ msg = EmailMessage()
24
+ msg["From"] = ec.sender
25
+ msg["To"] = ec.recipient
26
+ msg["Subject"] = "android-watcher digest"
27
+ msg.set_content(plaintext)
28
+ msg.add_alternative(html, subtype="html")
29
+
30
+ context = ssl.create_default_context()
31
+ try:
32
+ if ec.smtp_port == 465:
33
+ with smtplib.SMTP_SSL(ec.smtp_host, ec.smtp_port, context=context) as s:
34
+ s.login(ec.username, ec.password)
35
+ s.send_message(msg)
36
+ else:
37
+ with smtplib.SMTP(ec.smtp_host, ec.smtp_port) as s:
38
+ s.ehlo()
39
+ if not s.has_extn("starttls"):
40
+ raise NotifyError(
41
+ f"SMTP server {ec.smtp_host}:{ec.smtp_port} does not advertise "
42
+ "STARTTLS; refusing to send over plaintext"
43
+ )
44
+ s.starttls(context=context)
45
+ s.ehlo()
46
+ s.login(ec.username, ec.password)
47
+ s.send_message(msg)
48
+ except NotifyError:
49
+ raise
50
+ except (smtplib.SMTPException, OSError, ssl.SSLError) as exc:
51
+ raise NotifyError(f"email send failed: {exc}") from exc
52
+ return {m.id for g in digest.groups for m in g.members if m.id is not None}
@@ -0,0 +1,114 @@
1
+ """Render a Digest into a standalone full-digest HTML page (all groups)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html as _html
6
+
7
+ from android_watcher.models import Digest, DigestGroup
8
+ from android_watcher.rank import by_category
9
+
10
+ _HEAD = """<!doctype html>
11
+ <html lang="en"><head><meta charset="utf-8">
12
+ <meta name="viewport" content="width=device-width, initial-scale=1">
13
+ <title>Android Watcher Digest</title>
14
+ <link rel="preconnect" href="https://fonts.googleapis.com">
15
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet">
16
+ <style>
17
+ :root{--ink:#14171f;--paper:#faf9f5;--muted:#6b7180;--faint:#9aa0ac;--hair:#e7e6df;--signal:#1f9d57;--signal-soft:#e6f4ec;--card:#fff;--display:"Space Grotesk",system-ui,sans-serif;--body:"Inter",system-ui,sans-serif;--mono:"JetBrains Mono",ui-monospace,Menlo,monospace}
18
+ @media(prefers-color-scheme:dark){:root{--ink:#e9e8e2;--paper:#101218;--muted:#9aa0ac;--faint:#6b7180;--hair:#262932;--signal:#3ddc84;--signal-soft:#15271d;--card:#171a22}}
19
+ *{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--body);font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased}
20
+ .wrap{max-width:720px;margin:0 auto;padding:0 20px 72px}
21
+ header.mast{text-align:center;padding:48px 0 28px}
22
+ .mast h1{font-family:var(--display);font-weight:700;font-size:40px;letter-spacing:-.02em;margin:0;line-height:1.05}
23
+ .scan{width:64px;height:2px;margin:22px auto 18px;border-radius:2px;background:linear-gradient(90deg,transparent,var(--signal),transparent)}
24
+ .mast .stats{font-family:var(--mono);font-size:12.5px;color:var(--muted);display:flex;gap:14px;justify-content:center;flex-wrap:wrap}
25
+ .mast .stats b{color:var(--ink);font-weight:600}
26
+ .cat{display:flex;align-items:baseline;gap:12px;margin:38px 0 10px}
27
+ .cat h2{font-family:var(--mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:500;margin:0;white-space:nowrap}
28
+ .cat .rule{flex:1;height:1px;background:var(--hair)}.cat .n{font-family:var(--mono);font-size:12px;color:var(--faint)}
29
+ .row{background:var(--card);border:1px solid var(--hair);border-radius:12px;margin:8px 0;padding:16px 18px}
30
+ .titleline{display:flex;align-items:center;gap:10px}.tick{width:7px;height:7px;border-radius:50%;background:var(--signal);flex:none}
31
+ .title{font-family:var(--display);font-weight:600;font-size:17px;letter-spacing:-.01em}
32
+ .src{font-family:var(--mono);font-size:11px;color:var(--faint);margin:7px 0 0 17px}
33
+ .point{margin:9px 0 0 17px;color:var(--ink)}
34
+ .lbl{font-family:var(--mono);font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin:13px 0 6px 17px}
35
+ ol.sources{margin:0 0 0 17px;padding-left:22px}ol.sources li{margin:4px 0}
36
+ .src-one{margin:11px 0 0 17px;font-size:14px}
37
+ a{color:var(--signal)}a:hover{text-decoration:underline}.sources a,.src-one a{text-decoration:underline}
38
+ .banner{margin:0 0 24px;padding:12px 18px;border:1px solid #e6a817;border-radius:10px;background:#fffbee;color:#7a4f00;font-family:var(--mono);font-size:12.5px;text-align:center}
39
+ @media(prefers-color-scheme:dark){.banner{border-color:#7a4f00;background:#1e1600;color:#f5c842}}
40
+ .coverage{margin-top:44px;padding:24px;border:1px solid var(--hair);border-radius:14px;background:var(--card);display:flex;text-align:center}
41
+ .coverage .cell{flex:1;padding:4px 8px}.coverage .cell+.cell{border-left:1px solid var(--hair)}
42
+ .coverage .num{font-family:var(--display);font-weight:700;font-size:34px;letter-spacing:-.02em;color:var(--signal);line-height:1}
43
+ .coverage .lbl{font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);margin-top:8px}
44
+ footer{text-align:center;margin-top:22px;font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint)}
45
+ </style></head><body><div class="wrap">"""
46
+
47
+ _FOOT = "</div></body></html>"
48
+
49
+
50
+ def _esc(s: str) -> str:
51
+ return _html.escape(s or "")
52
+
53
+
54
+ def clean_label(title: str) -> str:
55
+ """Display form of a page title: drop a trailing ' | Site' / ' — Site' suffix
56
+ so link text reads as the page name, not 'Page | Android Open Source Project'."""
57
+ if not title:
58
+ return ""
59
+ for sep in (" | ", " — ", " – ", " · "):
60
+ if sep in title:
61
+ return title.split(sep)[0].strip()
62
+ return title.strip()
63
+
64
+
65
+ def _link(m) -> str:
66
+ label = clean_label(m.title) or m.url
67
+ return f'<a href="{_esc(m.url)}">{_esc(label)}</a>'
68
+
69
+
70
+ def _row(g: DigestGroup) -> str:
71
+ parts = [
72
+ '<div class="row">',
73
+ f'<div class="titleline"><span class="tick"></span>'
74
+ f'<span class="title">{_esc(clean_label(g.title))}</span></div>',
75
+ f'<div class="src">{_esc(g.source_id)} · {_esc(g.change_kind)}</div>',
76
+ ]
77
+ if g.summary:
78
+ parts.append(f'<p class="point">{_esc(g.summary)}</p>')
79
+ if g.page_count > 1:
80
+ items = "".join(f"<li>{_link(m)}</li>" for m in g.members)
81
+ parts.append(f'<div class="lbl">Sources</div><ol class="sources" type="i">{items}</ol>')
82
+ else:
83
+ parts.append(f'<div class="src-one">Source: {_link(g.members[0])}</div>')
84
+ parts.append("</div>")
85
+ return "".join(parts)
86
+
87
+
88
+ def render_html(digest: Digest) -> str:
89
+ date = digest.generated_at.strftime("%d %b %Y")
90
+ parts = [_HEAD]
91
+ parts.append(
92
+ '<header class="mast"><h1>Android Watcher Digest</h1><div class="scan"></div>'
93
+ f'<div class="stats"><span><b>{date}</b></span>'
94
+ f"<span><b>{len(digest.groups)}</b> groups</span>"
95
+ f"<span><b>{digest.change_count()}</b> changes</span></div></header>"
96
+ )
97
+ if digest.ai_unavailable:
98
+ parts.append(f'<div class="banner">AI unavailable: {_esc(digest.ai_unavailable)}</div>')
99
+ for _cid, label, groups in by_category(digest.groups):
100
+ parts.append(
101
+ f'<div class="cat"><h2>{_esc(label)}</h2><span class="rule"></span>'
102
+ f'<span class="n">{len(groups)}</span></div>'
103
+ )
104
+ parts.extend(_row(g) for g in groups)
105
+ parts.append(
106
+ '<div class="coverage">'
107
+ f'<div class="cell"><div class="num">{digest.sources_scanned}</div>'
108
+ '<div class="lbl">sources scanned</div></div>'
109
+ f'<div class="cell"><div class="num">{digest.pages_watched:,}</div>'
110
+ '<div class="lbl">pages watched</div></div></div>'
111
+ "<footer>Android Watcher Digest</footer>"
112
+ )
113
+ parts.append(_FOOT)
114
+ return "".join(parts)
@@ -0,0 +1,239 @@
1
+ """Render a Digest into email (HTML + plaintext), Telegram, and Slack blocks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html as _html
6
+
7
+ from android_watcher.models import Digest, DigestGroup
8
+ from android_watcher.notify.html import clean_label, render_html
9
+ from android_watcher.rank import by_category
10
+
11
+
12
+ def _banner_text(digest: Digest) -> str | None:
13
+ return f"AI unavailable: {digest.ai_unavailable}" if digest.ai_unavailable else None
14
+
15
+
16
+ def _scan_footer(digest: Digest) -> str | None:
17
+ """Scan-scope line shown at the foot of every delivered digest, or None."""
18
+ if not digest.sources_scanned:
19
+ return None
20
+ return f"Scanned {digest.sources_scanned} Sources · Watching {digest.pages_watched:,} Pages"
21
+
22
+
23
+ def _points_plaintext(digest: Digest) -> list[str]:
24
+ lines: list[str] = []
25
+ for _cid, label, groups in by_category(digest.groups):
26
+ lines.append(f"## {label}")
27
+ for g in groups:
28
+ pages = f" ({g.page_count} pages)" if g.page_count > 1 else ""
29
+ lines.append(f"- {g.title}{pages} [{g.source_id}/{g.change_kind}]")
30
+ if g.summary:
31
+ lines.append(f" {g.summary}")
32
+ return lines
33
+
34
+
35
+ def render_email(digest: Digest) -> tuple[str, str]:
36
+ html = render_html(digest)
37
+ p_lines: list[str] = []
38
+ banner = _banner_text(digest)
39
+ if banner:
40
+ p_lines.append(banner)
41
+ p_lines.append("Android Watcher Digest")
42
+ if digest.is_empty:
43
+ p_lines.append("Nothing notable changed.")
44
+ else:
45
+ p_lines.extend(_points_plaintext(digest))
46
+ footer = _scan_footer(digest)
47
+ if footer:
48
+ p_lines.append(footer)
49
+ return html, "\n".join(p_lines)
50
+
51
+
52
+ _TELEGRAM_LIMIT = 4096
53
+
54
+
55
+ def render_telegram(digest: Digest) -> str:
56
+ """Render a digest as an HTML-formatted Telegram message.
57
+
58
+ Uses parse_mode=HTML. User/content text is html-escaped. If the assembled
59
+ message would exceed Telegram's 4096-character limit, groups are dropped
60
+ (trailing ones first) and a "…(N more)" note is appended.
61
+ """
62
+ parts: list[str] = ["<b>Android Watcher Digest</b>"]
63
+ banner = _banner_text(digest)
64
+ if banner:
65
+ parts.insert(0, f"<b>{_html.escape(banner)}</b>")
66
+ scan = _scan_footer(digest)
67
+ scan_line = f"<i>{_html.escape(scan)}</i>" if scan else None
68
+ if digest.is_empty:
69
+ parts.append("Nothing notable changed.")
70
+ if scan_line:
71
+ parts.append(scan_line)
72
+ return "\n".join(parts)
73
+
74
+ item_lines: list[str] = []
75
+ for g in digest.message_groups():
76
+ pages = f" ({g.page_count} pages)" if g.page_count > 1 else ""
77
+ label = _html.escape(g.title)
78
+ src_tag = f"[{_html.escape(g.source_id)}/{_html.escape(g.change_kind)}]{pages}"
79
+ line = f'<a href="{_html.escape(g.primary_url)}">{label}</a> {src_tag}'
80
+ if g.summary:
81
+ line += f"\n{_html.escape(g.summary)}"
82
+ item_lines.append(line)
83
+
84
+ carried = digest.carried_groups()
85
+ footer_lines = [f"+{len(carried)} more groups"] if carried else []
86
+ header = "\n".join(parts)
87
+ footer = "\n".join(footer_lines)
88
+
89
+ def _assemble(items: list[str], dropped: int) -> str:
90
+ sections = [header, *items]
91
+ if dropped:
92
+ sections.append(f"…({dropped} more)")
93
+ if footer:
94
+ sections.append(footer)
95
+ if scan_line:
96
+ sections.append(scan_line)
97
+ return "\n".join(sections)
98
+
99
+ msg = _assemble(item_lines, 0)
100
+ if len(msg) <= _TELEGRAM_LIMIT:
101
+ return msg
102
+
103
+ # Drop groups from the end until it fits.
104
+ dropped = 0
105
+ while item_lines and len(_assemble(item_lines, dropped)) > _TELEGRAM_LIMIT:
106
+ item_lines.pop()
107
+ dropped += 1
108
+
109
+ return _assemble(item_lines, dropped)
110
+
111
+
112
+ _MEMBER_LINK_CAP = 12 # most member links to inline before summarizing the rest
113
+
114
+
115
+ def _slack_link_label(text: str) -> str:
116
+ """Clean a page title for display, then escape what would break a Slack
117
+ `<url|label>` link: a raw `|` ends the label, and `& < >` need escaping."""
118
+ label = clean_label(text)
119
+ return label.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("|", "/")
120
+
121
+
122
+ _ROMAN = [
123
+ (1000, "m"),
124
+ (900, "cm"),
125
+ (500, "d"),
126
+ (400, "cd"),
127
+ (100, "c"),
128
+ (90, "xc"),
129
+ (50, "l"),
130
+ (40, "xl"),
131
+ (10, "x"),
132
+ (9, "ix"),
133
+ (5, "v"),
134
+ (4, "iv"),
135
+ (1, "i"),
136
+ ]
137
+
138
+
139
+ def _roman(n: int) -> str:
140
+ """Lowercase roman numeral for the source list (i, ii, iii, …)."""
141
+ out: list[str] = []
142
+ for value, sym in _ROMAN:
143
+ while n >= value:
144
+ out.append(sym)
145
+ n -= value
146
+ return "".join(out)
147
+
148
+
149
+ def _group_section(g: DigestGroup, number: int | None = None) -> dict:
150
+ """One section per group: bold 'Title:' (numbered when its category holds
151
+ more than one group), an optional normal-weight summary, then the source
152
+ link(s). A single-source group shows '*Source:* <link>'; a multi-source group
153
+ shows '*Sources:*' then a lowercase roman-numeral list (capped). No buttons:
154
+ one button could never represent a group's N urls."""
155
+ clean = clean_label(g.title)
156
+ title = clean if clean.endswith(":") else f"{clean}:"
157
+ head = f"{number}. {title}" if number else title
158
+ text = f"*{head}*"
159
+ if g.summary:
160
+ text += f"\n{g.summary}"
161
+ if g.page_count == 1:
162
+ m = g.members[0]
163
+ text += f"\n\n*Source:* <{m.url}|{_slack_link_label(m.title or m.url)}>"
164
+ else:
165
+ shown = g.members[:_MEMBER_LINK_CAP]
166
+ rows = "\n".join(
167
+ f"{_roman(i)}. <{m.url}|{_slack_link_label(m.title or m.url)}>"
168
+ for i, m in enumerate(shown, 1)
169
+ )
170
+ text += f"\n\n*Sources:*\n{rows}"
171
+ if g.page_count > _MEMBER_LINK_CAP:
172
+ text += f"\n…+{g.page_count - _MEMBER_LINK_CAP} more"
173
+ return {"type": "section", "text": {"type": "mrkdwn", "text": text}}
174
+
175
+
176
+ def render_slack(digest: Digest, *, thread_page: bool = False) -> dict:
177
+ """Render the capped Slack message. ``thread_page`` is True when the notifier
178
+ will also upload the full-digest HTML page into a thread; the footer then
179
+ mentions it. False omits the thread reference."""
180
+ blocks: list[dict] = []
181
+ banner = _banner_text(digest)
182
+ if banner:
183
+ blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"*{banner}*"}})
184
+ date = digest.generated_at.strftime("%d %b %Y")
185
+ blocks.append(
186
+ {
187
+ "type": "header",
188
+ "text": {
189
+ "type": "plain_text",
190
+ "text": f"Android Watcher Digest · {date}",
191
+ "emoji": True,
192
+ },
193
+ }
194
+ )
195
+ if digest.is_empty:
196
+ blocks.append(
197
+ {"type": "section", "text": {"type": "mrkdwn", "text": "Nothing notable changed."}}
198
+ )
199
+ footer = _scan_footer(digest)
200
+ if footer:
201
+ blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": footer}]})
202
+ return {"blocks": blocks}
203
+
204
+ shown = digest.message_groups()
205
+ # A native divider opens each category (Slack has no thick/thin rule, so the
206
+ # divider is the category separator and there is no inner rule). Groups in a
207
+ # category with more than one group are numbered 1., 2., …
208
+ for _cid, label, groups in by_category(shown):
209
+ blocks.append({"type": "divider"})
210
+ blocks.append(
211
+ {"type": "section", "text": {"type": "mrkdwn", "text": f"*{label} · {len(groups)}*"}}
212
+ )
213
+ numbered = len(groups) > 1
214
+ for i, g in enumerate(groups, 1):
215
+ blocks.append(_group_section(g, i if numbered else None))
216
+
217
+ carried = digest.carried_groups()
218
+ footer_bits: list[str] = []
219
+ if thread_page:
220
+ footer_bits.append(
221
+ f"Full digest - *{len(digest.groups)} groups"
222
+ f" · {digest.change_count()} changes* - Attached in :thread:"
223
+ )
224
+ if carried:
225
+ names = ", ".join(g.title for g in carried[:3])
226
+ footer_bits.append(f"+{len(carried)} more groups: {names}")
227
+ scan = _scan_footer(digest)
228
+ tail: list[dict] = []
229
+ if footer_bits:
230
+ tail.append(
231
+ {"type": "context", "elements": [{"type": "mrkdwn", "text": " ".join(footer_bits)}]}
232
+ )
233
+ if scan:
234
+ tail.append({"type": "context", "elements": [{"type": "mrkdwn", "text": scan}]})
235
+ # Closing divider before the footer region (only when there is a footer).
236
+ if tail:
237
+ blocks.append({"type": "divider"})
238
+ blocks.extend(tail)
239
+ return {"blocks": blocks}