Skip to content
Open
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
46 changes: 46 additions & 0 deletions adhdo2/E2E_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# ADHDo 2.0 P1 Hardware E2E (pi5-hailo)

> How boot catch-up interacts with the dispatcher and fixed-event schedule
> (and why they never double-fire) is documented in
> [docs/catchup-vs-schedule.md](docs/catchup-vs-schedule.md).
>
> Note (P2 quality pass): the heartbeat is no longer a `*/25` cron line —
> `install-cron.sh` now installs the `adhdo-heartbeat.timer` systemd user
> timer (true 25-min cadence). On-device step: re-run
> `~/adhdo2/scripts/install-cron.sh` after the next deploy so the old cron
> heartbeat line is replaced and the timer is enabled.

## P3 Telegram bridge — on-device setup (human steps)

1. Create a bot with @BotFather on Telegram; copy the bot token.
2. On the Pi, put the token in `~/adhdo2/config.yaml` under
`telegram.bot_token` (or export `TELEGRAM_BOT_TOKEN` in the service
environment — env overrides config).
3. Message the bot once from your own Telegram account, then find your
numeric chat ID (e.g. via `curl "https://api.telegram.org/bot<TOKEN>/getUpdates"`
→ `message.chat.id`) and add it to `telegram.chat_id_allowlist` in
`~/adhdo2/config.yaml`.
4. `cp ~/adhdo2/systemd/adhdo-telegram.service ~/.config/systemd/user/ &&
systemctl --user daemon-reload && systemctl --user enable --now adhdo-telegram`
5. Verify:
- [ ] Text the bot "hello" → appears in the adhdo tmux pane as
`[telegram chat:<id>] User says: "hello"` and Claude responds.
- [ ] `bin/adhdo-telegram send <chat_id> "test reply"` → arrives in Telegram.
- [ ] Message from a non-allowlisted chat → ignored, `error` row in journal.
- [ ] Send a photo → bot replies "Text messages only"; nothing injected.
- [ ] 7 rapid messages in a minute → later ones get a rate-limit reply.

Run 2026-07-10, automated portion only (no audible verification performed —
that requires a human physically listening at the Pi's location).

Expand Down Expand Up @@ -81,6 +111,12 @@ that requires a human physically listening at the Pi's location).
SKIPPED — explicitly instructed not to reboot the Pi during this
automated pass.

- [ ] Nightly rollup: after re-running `install-cron.sh` (adds a 03:15
`adhdo-rollup.sh` entry), verify next morning that `journal rollup`
wrote rows (`sqlite3 ~/adhdo2/data/journal.db "SELECT * FROM rollups"`)
and `~/adhdo2/data/backup/journal.db` exists. Depends on cron being
enabled (human-gated switch below).

- [ ] Full heartbeat observed: cron fires, session runs state, journals a decision
SKIPPED — install-cron.sh intentionally NOT run yet (final human-gated
switch, see Step 4 notes). No heartbeat cron installed, so nothing to
Expand Down Expand Up @@ -197,3 +233,13 @@ installed as a safety net per Step 1).
`media_controller.status` until idle, max 30s) was NOT implemented — it
requires audible hardware observation to tune correctly, which is a human
step.
8. P3 dashboard (`bin/adhdo-dashboard`, port 8766): on-device verification —
`systemctl --user enable --now adhdo-dashboard` (unit in
`systemd/adhdo-dashboard.service`), then open
`http://<lan_ip>:8766/` from another LAN machine and confirm the page
shows live state (devices, disk, last-event ages) and recent journal rows,
auto-refreshing every 30s. Confirm `curl -X POST http://<lan_ip>:8766/`
returns 405 (read-only) and that the service is NOT reachable from outside
the LAN (binds to `dashboard.bind`, defaulting to `lan_ip`). Contract
tests (routing, JSON schemas, secret scrubbing, method rejection) are
covered in `tests/test_dashboard.py`.
7 changes: 6 additions & 1 deletion adhdo2/adhdolib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@
"high_urgency_events": ["medication", "safety", "user_requested"],
},
"disk_warn_pct": 94,
"dashboard": {"bind": None, "port": 8766},
"crisis": {"contacts": ["Lifeline Australia 13 11 14"]},
"telegram": {"chat_id_allowlist": []},
"telegram": {
"chat_id_allowlist": [],
"bot_token": None,
"rate_limit_per_minute": 6,
},
}

def home() -> Path:
Expand Down
6 changes: 3 additions & 3 deletions adhdo2/adhdolib/envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ def cli_main(fn):
except ToolError as e:
print(json.dumps({"error": e.code, "detail": scrub(e.detail)}))
sys.exit(1)
except SystemExit:
raise
except BaseException as e:
except Exception as e:
# Deliberately Exception, not BaseException: KeyboardInterrupt and
# SystemExit must propagate so Ctrl-C / exit codes behave normally.
tail = "".join(traceback.format_tb(e.__traceback__)[-3:])
print(json.dumps({"error": "crash",
"detail": scrub(f"{type(e).__name__}: {e}"),
Expand Down
8 changes: 8 additions & 0 deletions adhdo2/adhdolib/sanitize.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
def redact(text: str, secret) -> str:
"""Replace every occurrence of secret in text with REDACTED.
No-op when secret is falsy (no token configured)."""
if not secret:
return text
return text.replace(secret, "REDACTED")


def clean(text: str) -> str:
text = text.replace("\r\n", "\n").replace("\r", "\n")
text = text.replace("\n", "\\n")
Expand Down
150 changes: 150 additions & 0 deletions adhdo2/adhdolib/telegram.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""Telegram bridge core: allowlist, per-chat rate limit, advisory crisis
screen, journaling, and a stdlib-only (urllib) Telegram Bot API client.

Free text from Telegram is forwarded into the resident session through
bin/wake's inject() (literal tmux send-keys) — it can never become tmux
commands. The crisis screen is ADVISORY only: matched messages are flagged
in the journal and the forwarded payload, never blocked."""
import json, os, re, time, urllib.error, urllib.parse, urllib.request
from collections import defaultdict, deque

from . import db
from .envelope import ToolError
from .sanitize import redact

# Ported from the legacy src/mcp_server/llm_client.py SafetyMonitor
# crisis_patterns, tightened per the spec: the security_middleware.py
# patterns (crisis|emergency|help me) false-positive heavily on normal
# ADHD talk ("help me focus") and are deliberately NOT included.
CRISIS_PATTERNS = [re.compile(p, re.IGNORECASE) for p in (
r"\b(want to die|kill myself|end it all|suicide|suicidal)\b",
r"\b(harm myself|hurt myself|self[- ]harm)\b",
r"\b(no point (in )?living|life isn'?t worth|rather be dead)\b",
r"\b(can'?t go on|want to disappear|end the pain)\b",
)]


def crisis_screen(text: str) -> bool:
return any(p.search(text) for p in CRISIS_PATTERNS)


def neutralize(text: str) -> str:
"""Make user text inert inside the injected trusted framing.

Square brackets are the trusted-marker syntax (e.g. [telegram chat:N],
[crisis_advisory], [event:...]) and the payload wraps user text in double
quotes — so user-supplied brackets become parentheses and double quotes
are backslash-escaped, keeping the text readable while ensuring it can
never be parsed as trusted framing."""
return text.replace("[", "(").replace("]", ")").replace('"', '\\"')


def get_token(cfg: dict) -> str:
token = os.environ.get("TELEGRAM_BOT_TOKEN") or cfg["telegram"].get("bot_token")
if not token:
raise ToolError("no_token",
"set telegram.bot_token in config.yaml or TELEGRAM_BOT_TOKEN")
return token


class TelegramAPI:
"""Minimal Telegram Bot API client on urllib only."""

def __init__(self, token: str, base: str = "https://api.telegram.org"):
self.token = token
self.base = base

def _call(self, method: str, params: dict, timeout: float = 70):
url = f"{self.base}/bot{self.token}/{method}"
data = urllib.parse.urlencode(params).encode()
try:
with urllib.request.urlopen(url, data=data, timeout=timeout) as resp:
body = json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
detail = redact(f"{method}: HTTP {e.code} {e.read().decode(errors='replace')[:200]}",
self.token)
raise ToolError("telegram_api", detail)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
raise ToolError("telegram_api", redact(f"{method}: {type(e).__name__}: {e}", self.token))
if not body.get("ok"):
raise ToolError("telegram_api",
redact(f"{method}: {body.get('description', 'not ok')}", self.token))
return body["result"]

def get_updates(self, offset: int, timeout: int = 50):
return self._call("getUpdates",
{"offset": offset, "timeout": timeout,
"allowed_updates": '["message"]'},
timeout=timeout + 20)

def send_message(self, chat_id, text: str):
return self._call("sendMessage", {"chat_id": chat_id, "text": text}, timeout=30)


class Bridge:
"""Handles inbound updates: allowlist -> text-only -> rate limit ->
crisis screen (advisory) -> journal -> inject into the session."""

def __init__(self, cfg: dict, conn, injector, api: TelegramAPI):
tcfg = cfg["telegram"]
self.allowlist = {int(c) for c in tcfg["chat_id_allowlist"]}
self.rate_limit = int(tcfg.get("rate_limit_per_minute", 6))
self.conn = conn
self.injector = injector # wake.inject: returns "sent" or "queued"
self.api = api
self._recent = defaultdict(deque) # chat_id -> timestamps of forwarded msgs

def _rate_limited(self, chat_id, now: float) -> bool:
q = self._recent[chat_id]
while q and q[0] <= now - 60:
q.popleft()
if len(q) >= self.rate_limit:
return True
q.append(now)
return False

def _reply(self, chat_id, text: str):
"""Best-effort outbound courtesy reply: a sendMessage failure is
journaled and swallowed so it can never kill the daemon."""
try:
self.api.send_message(chat_id, text)
except Exception as e:
db.log_event(self.conn, "telegram", "error", json.dumps(
{"reason": "send_failed", "chat_id": chat_id,
"error": type(e).__name__}))

def handle_update(self, update: dict) -> dict:
msg = update.get("message")
if not msg or "chat" not in msg:
return {"action": "ignored"}
chat_id = msg["chat"]["id"]
if chat_id not in self.allowlist:
db.log_event(self.conn, "telegram", "error",
json.dumps({"reason": "chat_not_allowed", "chat_id": chat_id}))
return {"action": "denied", "chat_id": chat_id}
text = msg.get("text")
if not text:
# non-text messages count against the rate limit too, and the
# courtesy reply is suppressed once limited — a media flood can't
# generate unlimited outbound sendMessage calls.
limited = self._rate_limited(chat_id, time.time())
db.log_event(self.conn, "telegram", "error", json.dumps(
{"reason": "non_text_refused", "chat_id": chat_id,
"rate_limited": limited}))
if not limited:
self._reply(chat_id, "Text messages only, sorry — media is ignored.")
return {"action": "refused_media", "chat_id": chat_id,
"rate_limited": limited}
if self._rate_limited(chat_id, time.time()):
db.log_event(self.conn, "telegram", "error",
json.dumps({"reason": "rate_limited", "chat_id": chat_id}))
self._reply(chat_id, "Slow down a moment — rate limit hit, try again shortly.")
return {"action": "rate_limited", "chat_id": chat_id}
crisis = crisis_screen(text)
db.log_event(self.conn, "telegram", "wake", json.dumps(
{"chat_id": chat_id, "text": text, "crisis_advisory": crisis}))
flag = " [crisis_advisory]" if crisis else ""
payload = f'[telegram chat:{chat_id}]{flag} User says: "{neutralize(text)}"'
delivery = self.injector(payload)
return {"action": "forwarded", "chat_id": chat_id,
"crisis_advisory": crisis, "delivery": delivery}
Loading
Loading