diff --git a/CLAUDE.md b/CLAUDE.md index ef900e4..b1f706e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,9 +7,11 @@ of truth. Build to these, not to memory. A self-hosted Python CLI that watches official Google Android sites, detects real (not cosmetic) changes on a schedule, uses Claude to triage and describe -them, ranks the result, and delivers a digest to email or Slack. Single user per -install. Configured through a Textual TUI that writes a TOML file and installs a -native scheduled job. +them, ranks the result, and delivers a digest to Slack or a desktop notification. +The email and telegram notifiers still ship and work when hand-configured, but the +setup TUI and docs surface only Slack and Desktop. Single user per install. +Configured through a Textual TUI that writes a TOML file and installs a native +scheduled job. ## Commands @@ -150,6 +152,10 @@ them without understanding why they exist. watched; on id collision a custom source overrides the catalog. The TUI writes the reserved id `["__none__"]` to mean "watch no catalog sources." - SMTP enforces TLS and fails closed. The Slack bot token is a secret. +- Surfaced channels are Slack and Desktop: the TUI and `config_to_toml` only manage/serialize those two. The email and + telegram notifiers, their `Config` dataclasses, and their `load_config` parsers stay intact, so a hand-added + `[channels.email]` / `[channels.telegram]` section still loads and delivers — they are hidden, not removed. A TUI + re-save drops any unsurfaced section it did not write. ### Conventions diff --git a/README.md b/README.md index 91c668d..9bd620b 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ bookmarks, and most of what changes is a typo fix or a template reflow. `android-watcher` checks a curated catalog of official sources on a schedule, detects real changes (not cosmetic churn), uses Claude to decide what is worth your attention and write a short description, ranks the result, and delivers a -digest to email, Slack, or Telegram. When nothing substantive changed, it says so -instead of padding a digest with noise. +digest to Slack or a desktop notification. When nothing substantive changed, it +says so instead of padding a digest with noise. Install it once, run the interactive setup wizard, and receive a daily (or hourly, or weekly) digest. @@ -128,24 +128,14 @@ empty = "send" # send | skip (what to do when nothing substantive chang # Optional per-source or per-category priority overrides (higher = ranked first). # "android-developers-blog" = 90 -[channels.email] -enabled = true -smtp_host = "smtp.example.com" -smtp_port = 465 # TLS required: implicit (465) or STARTTLS -username = "you@example.com" -password = "${ANDROID_WATCH_SMTP_PASSWORD}" # env-var ref recommended; see Security -from = "you@example.com" -to = "you@example.com" - [channels.slack] enabled = true bot_token = "${ANDROID_WATCH_SLACK_TOKEN}" # env-var ref recommended (bot token is a secret) channel = "C0123456789" # channel ID (or #channel-name) -[channels.telegram] +[channels.desktop] enabled = false -bot_token = "${ANDROID_WATCH_TELEGRAM_TOKEN}" # env-var ref recommended (bot token is a secret) -chat_id = "123456789" +sound = "Glass" # macOS notification sound; click opens the full digest # Add your own URLs (same shape as catalog entries): # [[custom_source]] @@ -160,18 +150,16 @@ chat_id = "123456789" ### Secrets -The secrets android-watcher uses are the SMTP password, the Slack bot token, and -the Telegram bot token. Use **environment-variable references** so those values -are never written into the config file: +The secret android-watcher uses is the Slack bot token. Use an +**environment-variable reference** so the value is never written into the config +file: ```sh -export ANDROID_WATCH_SMTP_PASSWORD="hunter2" export ANDROID_WATCH_SLACK_TOKEN="xoxb-..." -export ANDROID_WATCH_TELEGRAM_TOKEN="1234567890:AAF..." ``` -The config stores `${ANDROID_WATCH_SMTP_PASSWORD}` literally; the value is -resolved at runtime. Inline plaintext works but is discouraged. +The config stores `${ANDROID_WATCH_SLACK_TOKEN}` literally; the value is resolved +at runtime. Inline plaintext works but is discouraged. --- @@ -212,10 +200,10 @@ missed run on the next wake and catches up automatically. ## Security and privacy -**Secrets.** The secrets are the SMTP password, the Slack bot token, and the -Telegram bot token. The config file is written `0600`. Prefer environment-variable -references (`password = "${ANDROID_WATCH_SMTP_PASSWORD}"`) so plaintext values -are never written to disk. +**Secrets.** The secret is the Slack bot token. The config file is written +`0600`. Prefer an environment-variable reference +(`bot_token = "${ANDROID_WATCH_SLACK_TOKEN}"`) so plaintext values are never +written to disk. **Keep config out of git.** If you keep your config under version control, never commit a file with inline secrets. Add to `.gitignore`: @@ -227,8 +215,7 @@ config.toml ``` The TUI and `--config` warn when the config path is inside a git work tree, -because an accidental commit would expose your SMTP password, Slack bot token, or -Telegram bot token. +because an accidental commit would expose your Slack bot token. **AI data egress.** When `[ai] mode = "claude_cli"`, the **content of changed pages is sent to the `claude` CLI** for triage and description. For the shipped @@ -237,12 +224,7 @@ as an internal wiki), that page content is also sent to `claude`. Set `[ai] mode = "off"` for the no-egress path: no triage, no descriptions, no page content leaves your machine. -**SMTP transport.** SMTP enforces TLS (implicit on port 465 or mandatory -STARTTLS) with certificate verification. It fails closed rather than downgrading -to plaintext. - -**Slack and Telegram.** The Slack bot token and Telegram bot token are treated -as bearer secrets and are never logged. +**Slack.** The Slack bot token is treated as a bearer secret and is never logged. --- diff --git a/SECURITY.md b/SECURITY.md index 54a2dbf..bafa216 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,18 +10,15 @@ are not patched. `android-watcher` is a self-hosted, single-user CLI. It does not run as a service exposed to the network. -**Credentials at rest.** SMTP password and Slack bot token are stored in a -config file written `0600`. Both fields support `${ENV_VAR}` substitution so -secrets need not be written in plaintext — the literal `${...}` token is -preserved on disk; the value is resolved only at runtime from the environment. +**Credentials at rest.** The Slack bot token is stored in a config file written +`0600`. The field supports `${ENV_VAR}` substitution so the secret need not be +written in plaintext — the literal `${...}` token is preserved on disk; the value +is resolved only at runtime from the environment. **Outbound egress.** When AI triage is enabled, the tool shells out to the local `claude` CLI and passes fetched page content to it. That content is sent to Anthropic's API as part of the triage prompt. No other data leaves the machine. -**Transport.** SMTP connections enforce TLS and fail closed — plaintext delivery -is not attempted as a fallback. - ## Reporting a vulnerability Use GitHub's private security advisory feature: on the repository page go to diff --git a/src/android_watcher/cli.py b/src/android_watcher/cli.py index daf3105..49dc17c 100644 --- a/src/android_watcher/cli.py +++ b/src/android_watcher/cli.py @@ -100,7 +100,10 @@ def _cmd_tui(args: argparse.Namespace) -> int: # wizard from the start in that case too — not only when the file is absent. # (Short-circuits on a fresh install before touching the config fields.) first_run = (not existed) or not ( - config.email.enabled or config.slack.enabled or config.telegram.enabled + config.email.enabled + or config.slack.enabled + or config.telegram.enabled + or config.desktop.enabled ) result = AndroidWatcher(config=config, first_run=first_run).run() if isinstance(result, str): diff --git a/src/android_watcher/tui/configio.py b/src/android_watcher/tui/configio.py index b8f5e55..6710da3 100644 --- a/src/android_watcher/tui/configio.py +++ b/src/android_watcher/tui/configio.py @@ -79,12 +79,12 @@ def _in_git_worktree(path: str) -> bool: def config_to_toml(config: Config) -> str: """Serialize *config* to a TOML string. - Secret strings (password, slack bot_token, telegram bot_token) are written - exactly as held in Config — ${ENV_VAR} refs are preserved verbatim, never expanded. - EmailChannel.sender maps to the TOML key ``from``; .recipient maps to ``to``. + Only the surfaced channels (Slack, Desktop) are written. The Slack bot_token + is written exactly as held — a ${ENV_VAR} ref is preserved verbatim, never + expanded. """ sc, ai, dg = config.schedule, config.ai, config.digest - em, sl = config.email, config.slack + sl = config.slack lines: list[str] = [] # Top-level scalar keys must come before any section headers so TOML @@ -121,29 +121,16 @@ def config_to_toml(config: Config) -> str: lines.append(f"{_toml_str(key)} = {weight}") lines.append("") - lines.append("[channels.email]") - lines.append(f"enabled = {'true' if em.enabled else 'false'}") - lines.append(f"smtp_host = {_toml_str(em.smtp_host)}") - lines.append(f"smtp_port = {em.smtp_port}") - lines.append(f"username = {_toml_str(em.username)}") - lines.append(f"password = {_toml_str(em.password)}") - lines.append(f"from = {_toml_str(em.sender)}") - lines.append(f"to = {_toml_str(em.recipient)}") - lines.append("") - + # Only the surfaced channels (Slack, Desktop) are serialized. Email and + # Telegram remain fully functional in the code and load_config still parses a + # hand-added [channels.email] / [channels.telegram] section, but the TUI no + # longer writes or manages them. lines.append("[channels.slack]") lines.append(f"enabled = {'true' if sl.enabled else 'false'}") lines.append(f"bot_token = {_toml_str(sl.bot_token)}") lines.append(f"channel = {_toml_str(sl.channel)}") lines.append("") - tg = config.telegram - lines.append("[channels.telegram]") - lines.append(f"enabled = {'true' if tg.enabled else 'false'}") - lines.append(f"bot_token = {_toml_str(tg.bot_token)}") - lines.append(f"chat_id = {_toml_str(tg.chat_id)}") - lines.append("") - ds = config.desktop lines.append("[channels.desktop]") lines.append(f"enabled = {'true' if ds.enabled else 'false'}") @@ -187,24 +174,18 @@ def validate_config(config: Config) -> list[str]: errors.append(str(exc)) finally: os.unlink(tmp) + # Any enabled channel satisfies the requirement (a hand-configured email or + # telegram still counts), but the guidance names only the surfaced channels. if not ( config.email.enabled or config.slack.enabled or config.telegram.enabled or config.desktop.enabled ): - errors.append( - "enable at least one delivery channel (Email, Slack, Telegram, or Desktop) " - "to receive digests" - ) + errors.append("enable at least one delivery channel (Slack or Desktop) to receive digests") sl = config.slack if sl.enabled and not (sl.bot_token and sl.channel): errors.append("slack channel is enabled but bot_token + channel are required") - tg = config.telegram - if tg.enabled and not tg.bot_token: - errors.append("telegram channel is enabled but bot_token is empty") - if tg.enabled and not tg.chat_id: - errors.append("telegram channel is enabled but chat_id is empty") if config.desktop.enabled and not desktop_mechanism_available(): errors.append( "desktop channel is enabled but no notifier is available " diff --git a/src/android_watcher/tui/screens.py b/src/android_watcher/tui/screens.py index 59a81fb..1a1c51c 100644 --- a/src/android_watcher/tui/screens.py +++ b/src/android_watcher/tui/screens.py @@ -431,11 +431,7 @@ def action_back(self) -> None: def _summaries(self) -> list[tuple[str, str, str]]: c = self._config - channels = [ - name - for name, ch in (("slack", c.slack), ("telegram", c.telegram), ("desktop", c.desktop)) - if ch.enabled - ] + channels = [name for name, ch in (("slack", c.slack), ("desktop", c.desktop)) if ch.enabled] sched = c.schedule when = sched.cron if sched.interval == "cron" else f"{sched.interval} {sched.at}".strip() return [ @@ -750,7 +746,6 @@ def _channels(self): c = self._config return ( ("slack", "Slack", c.slack), - ("telegram", "Telegram", c.telegram), ("desktop", "Desktop", c.desktop), ) @@ -822,8 +817,6 @@ def _activate(self, option_id: str | None) -> None: match option_id: case "slack": self.app.push_screen(SlackScreen(self._config)) - case "telegram": - self.app.push_screen(TelegramScreen(self._config)) case "__done__": self._forward() @@ -866,19 +859,6 @@ def _fields(self) -> list[Field]: ] -class TelegramScreen(_ChannelScreen): - TITLE = "Telegram" - - def _channel(self): - return self._config.telegram - - def _fields(self) -> list[Field]: - return [ - Field("bot_token", "Bot token", "secret", help="From @BotFather"), - Field("chat_id", "Chat IDs", "text", help="Comma-separated user or group chat ids"), - ] - - class ReviewScreen(_Nav): """Final wizard step: review every choice, show where it saves, then save.""" @@ -916,11 +896,7 @@ def _summary_lines(self) -> list[str]: else: when = f"daily at {s.at}" ai = "off" if c.ai.mode == "off" else f"claude ({c.ai.model})" - channels = [ - n - for n, ch in (("slack", c.slack), ("telegram", c.telegram), ("desktop", c.desktop)) - if ch.enabled - ] + channels = [n for n, ch in (("slack", c.slack), ("desktop", c.desktop)) if ch.enabled] channels_str = ", ".join(channels) if channels else "none — pick one to finish!" return [ f"Sources {watched_count(c)} selected", diff --git a/tests/notify/test_telegram.py b/tests/notify/test_telegram.py index 77708fb..c4192f7 100644 --- a/tests/notify/test_telegram.py +++ b/tests/notify/test_telegram.py @@ -20,7 +20,6 @@ from android_watcher.models import Change, Digest, DigestGroup, NotifyError from android_watcher.notify.render import render_telegram from android_watcher.notify.telegram import TelegramNotifier -from android_watcher.tui.configio import validate_config # --------------------------------------------------------------------------- # Fixtures / helpers @@ -381,54 +380,7 @@ def test_bot_token_ref_unset_with_expand_false_does_not_raise( # --------------------------------------------------------------------------- -# validate_config: enabled telegram with missing fields -# --------------------------------------------------------------------------- - - -class TestValidateTelegram: - def _base_config(self) -> Config: - return Config( - schedule=ScheduleConfig(), - ai=AIConfig(), - digest=DigestConfig(), - sort={}, - email=EmailChannel(), - slack=SlackChannel(), - telegram=TelegramChannel(), - custom_sources=[], - enabled_source_ids=set(), - ) - - def test_enabled_with_missing_bot_token_is_error(self) -> None: - cfg = self._base_config() - cfg.telegram.enabled = True - cfg.telegram.bot_token = "" - cfg.telegram.chat_id = "123" - errors = validate_config(cfg) - assert any("bot_token" in e for e in errors) - - def test_enabled_with_missing_chat_id_is_error(self) -> None: - cfg = self._base_config() - cfg.telegram.enabled = True - cfg.telegram.bot_token = "sometoken" - cfg.telegram.chat_id = "" - errors = validate_config(cfg) - assert any("chat_id" in e for e in errors) - - def test_enabled_with_both_fields_is_valid(self) -> None: - cfg = self._base_config() - cfg.telegram.enabled = True - cfg.telegram.bot_token = "sometoken" - cfg.telegram.chat_id = "123" - errors = validate_config(cfg) - assert errors == [] - - def test_disabled_with_empty_fields_is_valid(self) -> None: - cfg = self._base_config() - cfg.telegram.enabled = False - # Another channel must be enabled for the config to be complete. - cfg.slack.enabled = True - cfg.slack.bot_token = "xoxb-test" - cfg.slack.channel = "#updates" - errors = validate_config(cfg) - assert errors == [] +# Telegram is a hidden channel: the TUI no longer manages it and +# config_to_toml drops the section, so validate_config does not validate its +# sub-fields. The notifier, render, loader, and ${ENV} interpolation above are +# the kept code that keeps a hand-configured telegram channel working. diff --git a/tests/test_configio.py b/tests/test_configio.py index f1a57a9..d51270a 100644 --- a/tests/test_configio.py +++ b/tests/test_configio.py @@ -96,17 +96,15 @@ def test_roundtrip_preserves_fields(tmp_path: Path) -> None: assert loaded.ai.model == "claude-opus-4-8" assert loaded.digest.max_items == 5 assert loaded.digest.empty == "send" - # Secret refs preserved verbatim (not expanded) - assert loaded.email.password == "${SMTP_PW}" + # Secret refs preserved verbatim (not expanded) for the surfaced channels. assert loaded.slack.bot_token == "${SLACK_BOT}" assert loaded.slack.channel == "#dev" - assert loaded.telegram.bot_token == "${TG_TOKEN}" - assert loaded.telegram.chat_id == "-100123" assert loaded.desktop.enabled is True assert loaded.desktop.sound == "Ping" - # sender/recipient mapping - assert loaded.email.sender == "from@example.com" - assert loaded.email.recipient == "to@example.com" + # Email and telegram are not serialized, so they round-trip back to defaults. + assert loaded.email.enabled is False + assert loaded.email.password == "" + assert loaded.telegram.enabled is False # Custom source assert len(loaded.custom_sources) == 1 src = loaded.custom_sources[0] @@ -122,47 +120,34 @@ def test_roundtrip_preserves_fields(tmp_path: Path) -> None: def test_env_refs_not_expanded_on_write(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """config_to_toml must write ${SMTP_PW} literally, even when the var is set.""" - monkeypatch.setenv("SMTP_PW", "secret") + """config_to_toml must write a ${ENV} secret literally, even when the var is set.""" + monkeypatch.setenv("SLACK_TOKEN", "secret") cfg = Config( schedule=ScheduleConfig(), ai=AIConfig(), digest=DigestConfig(), sort={}, - email=EmailChannel(enabled=True, smtp_host="smtp.example.com", password="${SMTP_PW}"), - slack=SlackChannel(), + email=EmailChannel(), + slack=SlackChannel(enabled=True, bot_token="${SLACK_TOKEN}", channel="#x"), telegram=TelegramChannel(), custom_sources=[], enabled_source_ids=set(), ) toml_text = config_to_toml(cfg) - assert "${SMTP_PW}" in toml_text + assert "${SLACK_TOKEN}" in toml_text assert "secret" not in toml_text -def test_from_to_key_mapping(tmp_path: Path) -> None: - """TOML output uses keys 'from' and 'to', not 'sender'/'recipient'.""" - cfg = Config( - schedule=ScheduleConfig(), - ai=AIConfig(), - digest=DigestConfig(), - sort={}, - email=EmailChannel(sender="a@b.com", recipient="c@d.com"), - slack=SlackChannel(), - telegram=TelegramChannel(), - custom_sources=[], - enabled_source_ids=set(), - ) - toml_text = config_to_toml(cfg) - assert 'from = "a@b.com"' in toml_text - assert 'to = "c@d.com"' in toml_text - assert "sender" not in toml_text - assert "recipient" not in toml_text - - # Round-trip: load_config must map them back to sender/recipient +def test_email_from_to_keys_load_to_sender_recipient(tmp_path: Path) -> None: + """The email notifier is hidden but kept: a hand-added [channels.email] section + still loads, with the TOML keys 'from'/'to' mapped to sender/recipient.""" p = tmp_path / "config.toml" - write_config(cfg, str(p)) + p.write_text( + '[channels.email]\nenabled = true\nfrom = "a@b.com"\nto = "c@d.com"\n', + encoding="utf-8", + ) loaded = load_config(str(p), expand=False) + assert loaded.email.enabled is True assert loaded.email.sender == "a@b.com" assert loaded.email.recipient == "c@d.com" @@ -170,7 +155,8 @@ def test_from_to_key_mapping(tmp_path: Path) -> None: def test_open_existing_preserves_env_ref_with_var_set( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """load_or_default with expand=False keeps ${AW_SMTP_PW} literal when var is set.""" + """load_or_default with expand=False keeps a hand-added [channels.email] + ${AW_SMTP_PW} literal when the var is set (the email loader is kept).""" config_file = tmp_path / "config.toml" config_file.write_text( '[channels.email]\nenabled = true\nsmtp_host = "smtp.example.com"\n' @@ -184,10 +170,6 @@ def test_open_existing_preserves_env_ref_with_var_set( assert existed is True assert cfg.email.password == "${AW_SMTP_PW}" - toml_text = config_to_toml(cfg) - assert "${AW_SMTP_PW}" in toml_text - assert "topsecret" not in toml_text - def test_open_existing_preserves_env_ref_with_var_unset( tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -402,25 +384,32 @@ def test_load_or_default_existing(tmp_path: Path, monkeypatch: pytest.MonkeyPatc assert cfg.schedule.interval == "weekly" -def test_telegram_roundtrip_preserves_env_ref(tmp_path: Path) -> None: - """[channels.telegram] round-trips with ${ENV} bot_token preserved verbatim.""" +def test_email_and_telegram_omitted_from_serialization_but_still_load(tmp_path: Path) -> None: + """Hidden channels: config_to_toml writes neither email nor telegram, but a + hand-added section still loads and is usable (the notifier code is kept).""" cfg = Config( schedule=ScheduleConfig(), ai=AIConfig(), digest=DigestConfig(), sort={}, - email=EmailChannel(), + email=EmailChannel(enabled=True, smtp_host="smtp.example.com"), slack=SlackChannel(), telegram=TelegramChannel(enabled=True, bot_token="${TG_TOKEN}", chat_id="-100123"), custom_sources=[], enabled_source_ids=set(), ) toml_text = config_to_toml(cfg) - assert "[channels.telegram]" in toml_text - assert "${TG_TOKEN}" in toml_text + assert "[channels.email]" not in toml_text + assert "[channels.telegram]" not in toml_text + assert "[channels.slack]" in toml_text + assert "[channels.desktop]" in toml_text + # A hand-added [channels.telegram] section still loads with its ${ENV} ref intact. p = tmp_path / "config.toml" - write_config(cfg, str(p)) + p.write_text( + '[channels.telegram]\nenabled = true\nbot_token = "${TG_TOKEN}"\nchat_id = "-100123"\n', + encoding="utf-8", + ) loaded = load_config(str(p), expand=False) assert loaded.telegram.enabled is True assert loaded.telegram.bot_token == "${TG_TOKEN}" diff --git a/tests/test_tui_smoke.py b/tests/test_tui_smoke.py index aea18fa..da5104a 100644 --- a/tests/test_tui_smoke.py +++ b/tests/test_tui_smoke.py @@ -26,7 +26,6 @@ SlackScreen, SourcesGateScreen, SourcesScreen, - TelegramScreen, WelcomeScreen, ) @@ -319,7 +318,8 @@ async def test_channels_hub_configure_and_done_returns(): @pytest.mark.asyncio -async def test_email_not_offered_in_channels(): +async def test_email_and_telegram_not_offered_in_channels(): + """Only the surfaced channels (Slack, Desktop) appear in the TUI hub.""" cfg = _blank_config() app = AndroidWatcher(config=cfg, first_run=False) async with app.run_test() as pilot: @@ -330,8 +330,8 @@ async def test_email_not_offered_in_channels(): app.screen.query_one("#ch-list", OptionList).get_option_at_index(i).id for i in range(app.screen.query_one("#ch-list", OptionList).option_count) } - assert "email" not in ids - assert {"slack", "telegram"} <= ids + assert ids.isdisjoint({"email", "telegram"}) + assert {"slack", "desktop"} <= ids @pytest.mark.asyncio @@ -342,19 +342,19 @@ async def test_inline_edit_sets_value_no_new_screen(): await pilot.pause() app.screen._activate("channels") # type: ignore[attr-defined] await pilot.pause() - app.screen._activate("telegram") # type: ignore[attr-defined] + app.screen._activate("slack") # type: ignore[attr-defined] await pilot.pause() screen = app.screen - assert isinstance(screen, TelegramScreen) - screen._open_editor(screen._field("chat_id")) # type: ignore[attr-defined] + assert isinstance(screen, SlackScreen) + screen._open_editor(screen._field("channel")) # type: ignore[attr-defined] await pilot.pause() editor = screen.query_one("#editor", Input) assert editor.display is True # inline editor, same screen - screen.on_input_submitted(Input.Submitted(editor, "12345")) + screen.on_input_submitted(Input.Submitted(editor, "#updates")) await pilot.pause() - assert isinstance(app.screen, TelegramScreen) # no new screen pushed - assert cfg.telegram.chat_id == "12345" - assert cfg.telegram.enabled is True + assert isinstance(app.screen, SlackScreen) # no new screen pushed + assert cfg.slack.channel == "#updates" + assert cfg.slack.enabled is True @pytest.mark.asyncio