Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
48 changes: 15 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]]
Expand All @@ -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.

---

Expand Down Expand Up @@ -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`:
Expand All @@ -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
Expand All @@ -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.

---

Expand Down
11 changes: 4 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/android_watcher/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
41 changes: 11 additions & 30 deletions src/android_watcher/tui/configio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'}")
Expand Down Expand Up @@ -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 "
Expand Down
28 changes: 2 additions & 26 deletions src/android_watcher/tui/screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -750,7 +746,6 @@ def _channels(self):
c = self._config
return (
("slack", "Slack", c.slack),
("telegram", "Telegram", c.telegram),
("desktop", "Desktop", c.desktop),
)

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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",
Expand Down
56 changes: 4 additions & 52 deletions tests/notify/test_telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Loading
Loading