|
| 1 | +# Phase 1b — Bot Foundations |
| 2 | + |
| 3 | +> Source: `docs/current-arch/ARCHITECTURE.md` §3, §6.9, §14.1-14.2 |
| 4 | +> Depends on: Phase 1a complete |
| 5 | +
|
| 6 | +## Goal |
| 7 | + |
| 8 | +Classical bot runtime with deterministic command routing. First chat |
| 9 | +connector (Telegram). No AI. Users talk to the bot through Telegram and |
| 10 | +get the same automation they configured in Phase 1a, plus interactive |
| 11 | +commands. |
| 12 | + |
| 13 | +## Two Sub-Milestones |
| 14 | + |
| 15 | +**1b-i: Bot core** — testable without any chat platform. |
| 16 | +**1b-ii: First chat connector** — Telegram makes the bot reachable. |
| 17 | + |
| 18 | +## Milestone 1b-i: springtale-bot |
| 19 | + |
| 20 | +The bot runtime lives in `crates/springtale-bot/`. It depends on core, |
| 21 | +crypto, connector, store, and transport. |
| 22 | + |
| 23 | +**How the event loop works:** |
| 24 | + |
| 25 | +```rust |
| 26 | +pub async fn run(bot: &Bot) -> Result<()> { |
| 27 | + loop { |
| 28 | + tokio::select! { |
| 29 | + Some(msg) = bot.connector_events.recv() => { |
| 30 | + // Route through command router |
| 31 | + // On error: log + continue (NEVER propagate ? — kills the loop) |
| 32 | + } |
| 33 | + Some(trigger) = bot.rule_events.recv() => { |
| 34 | + // Evaluate and dispatch through rule engine |
| 35 | + } |
| 36 | + Some(job) = bot.job_events.recv() => { |
| 37 | + // Execute scheduled job |
| 38 | + } |
| 39 | + } |
| 40 | + } |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +**Critical design detail:** The event loop must NOT use `?` on individual |
| 45 | +message processing. A single bad message must not crash the bot. Log the |
| 46 | +error, continue to next event. This was flagged in the architecture audit. |
| 47 | + |
| 48 | +**Command router (no AI):** |
| 49 | +- `router::prefix` — exact prefix match: `/search`, `/help`, `/remind`, `/status` |
| 50 | +- `router::pattern` — regex and keyword matching for non-slash triggers |
| 51 | +- `router::alias` — user-defined aliases persisted in SQLite (e.g., `/s` -> `/search`) |
| 52 | +- `router::fallback` — no match returns "Unknown command. Try /help" (AI fallback added in Phase 2a) |
| 53 | +- Router is a pure function: `(message_text) -> RouteResult::Command(name, args) | RouteResult::NoMatch` |
| 54 | + |
| 55 | +**Handler dispatch:** |
| 56 | +- `handler::registry` — HashMap of command name -> handler function |
| 57 | +- `handler::builtin` — `/help` (list commands), `/status` (bot health), `/rules` (list active rules), `/connectors` (list installed) |
| 58 | +- `handler::connector` — generic handler: connector name + action derived from command. When connector-presearch is installed, `/search <query>` auto-registers. |
| 59 | + |
| 60 | +**How auto-registration works:** When a connector is installed via |
| 61 | +`springtale connector install`, the bot reads its `actions()` and |
| 62 | +registers each as a command. `connector-presearch` with action `search` |
| 63 | +becomes `/search`. `connector-github` with action `create_issue` becomes |
| 64 | +`/github create_issue`. Users create aliases for convenience. |
| 65 | + |
| 66 | +**Conversation state:** |
| 67 | +- `state::session` — per `(user_id, channel_id)` key in SQLite. Tracks what the bot last said, what it's waiting for (multi-step flows). Row-level isolation: no cross-user state access. |
| 68 | +- `state::prefs` — user preferences: timezone, language, notification settings. Persisted in SQLite. User manages via `/prefs set timezone America/New_York`. |
| 69 | +- `state::persona` — bot persona config: name, response tone, template library. Loaded from `springtale.toml`. Not user-configurable (admin sets it). |
| 70 | + |
| 71 | +**Persistent memory:** |
| 72 | +- `memory::persistent` — SQLite-backed. Structured typed schemas, not arbitrary strings. Encrypted at rest via vault. |
| 73 | +- `memory::context` — sliding window of recent conversation per user. Configurable size (default: 50 messages). |
| 74 | +- `memory::compaction` — Phase 1b: simple truncation (drop oldest). Phase 2a: AI summarization when adapter is plugged in. |
| 75 | + |
| 76 | +**Bot identity:** |
| 77 | +- `identity::bot_id` — Ed25519 keypair generated via springtale-crypto. Stored in vault. |
| 78 | +- Phase 1b: keypair is the bot's identity. Simple. |
| 79 | +- Phase 3: HKDF derives per-community pseudonyms from this keypair for Rekindle integration. The derivation code is not written yet — just the keypair. |
| 80 | + |
| 81 | +**Safety features (from audit §2.8 IPV threat model):** |
| 82 | +- No OS notifications by default. Connector events and bot responses do not trigger push notifications unless user explicitly enables via `/prefs set notifications on`. |
| 83 | +- Vault auto-lock after configurable inactivity (default: 5 min). CLI prompts for passphrase to resume. Tauri modal in Phase 2b. |
| 84 | + |
| 85 | +**Integration with springtaled:** |
| 86 | +- `springtaled` startup adds a new step between scheduler start and API start: initialize `springtale-bot` with references to connector registry, rule engine, scheduler, and store |
| 87 | +- Bot event loop runs as a tokio task alongside existing scheduler tasks |
| 88 | +- The rule engine from Phase 1a runs INSIDE the bot's event loop — cron rules, webhook rules, filesystem rules all fire through the same dispatch path |
| 89 | + |
| 90 | +**Testing (without Telegram):** |
| 91 | +- Headless integration test: fire a `Trigger::ConnectorEvent` -> bot routes -> handler executes -> response generated |
| 92 | +- Command routing tests: prefix match, pattern match, alias resolution, fallback |
| 93 | +- Session isolation tests: two users send commands concurrently, state does not leak |
| 94 | +- Memory compaction test: fill to N+1, verify oldest dropped |
| 95 | + |
| 96 | +## Milestone 1b-ii: connector-telegram |
| 97 | + |
| 98 | +Standard connector structure per `.claude/rules/connector-guidelines.md`. |
| 99 | + |
| 100 | +**How to build:** |
| 101 | + |
| 102 | +Telegram Bot API client. Two modes of receiving updates: |
| 103 | +1. **Webhook mode:** Telegram sends HTTPS POST to your endpoint. Requires public URL. Uses the management API's webhook endpoint: `POST /webhook/connector-telegram/message_received`. |
| 104 | +2. **Long-polling mode:** Bot calls `getUpdates` in a loop. Works behind NAT. Default for dev/self-hosted. |
| 105 | + |
| 106 | +Mode selected in connector config. Long-polling is default. |
| 107 | + |
| 108 | +**Auth:** |
| 109 | +- Bot token from BotFather, stored as `Secret<String>` in connector config |
| 110 | +- Webhook signature verification not natively supported by Telegram Bot API (unlike GitHub). Instead: use secret path token in webhook URL as auth. |
| 111 | + |
| 112 | +**Connector trait implementation:** |
| 113 | +- `triggers()`: `message_received`, `command_received` (filtered by /prefix) |
| 114 | +- `actions()`: `send_message`, `send_photo`, `edit_message`, `delete_message`, `send_inline_keyboard` |
| 115 | +- `execute("send_message", { chat_id, text, parse_mode })` -> call Telegram `sendMessage` API |
| 116 | +- `on_event("message_received", handler)` -> handler called for every incoming message |
| 117 | + |
| 118 | +**Typed API client:** |
| 119 | +- `client::api.rs` — reqwest client to `https://api.telegram.org/bot{token}/` |
| 120 | +- All request/response types as Rust structs with serde |
| 121 | +- Methods: `send_message`, `send_photo`, `edit_message_text`, `delete_message`, `get_updates`, `set_webhook`, `delete_webhook` |
| 122 | +- Inline keyboard builder for interactive buttons |
| 123 | +- Message formatting: MarkdownV2 and HTML modes |
| 124 | + |
| 125 | +**Research needed:** Telegram Bot API docs (https://core.telegram.org/bots/api). |
| 126 | +Update polling semantics (`offset` parameter for confirming received updates). |
| 127 | +MarkdownV2 escaping rules (Telegram has unusual escaping requirements). |
| 128 | +Rate limits (30 messages/second to same chat, 20 messages/minute to same group). |
| 129 | + |
| 130 | +**Integration:** |
| 131 | +- Install: `springtale connector install ./connector-telegram.toml` |
| 132 | +- Bot auto-registers Telegram message handler in event loop |
| 133 | +- User sends `/search tokyo weather` in Telegram chat |
| 134 | +- Bot receives via connector-telegram `on_event` |
| 135 | +- Router matches `/search` prefix -> SearchHandler |
| 136 | +- Handler calls `connector-presearch.execute("search", { query: "tokyo weather" })` |
| 137 | +- Result formatted via response template |
| 138 | +- Handler calls `connector-telegram.execute("send_message", { chat_id, text: formatted_result })` |
| 139 | +- User sees result in Telegram |
| 140 | + |
| 141 | +**Testing:** |
| 142 | +- Mock Telegram API server (axum test server returning canned responses) |
| 143 | +- Test: send_message serialization matches Telegram API format |
| 144 | +- Test: getUpdates polling loop handles timeout, empty response, error response |
| 145 | +- Integration test: message received -> routed -> connector called -> reply sent |
| 146 | + |
| 147 | +## Not In Phase 1b |
| 148 | + |
| 149 | +- No AI fallback parser (Phase 2a — router returns "unknown command" for now) |
| 150 | +- No AI-powered memory compaction (Phase 2a — truncation only) |
| 151 | +- No HKDF pseudonym derivation on BotId (Phase 3) |
| 152 | +- No recursive pipeline orchestration (Phase 2a) |
| 153 | +- No sub-agent spawning (Phase 2a) |
| 154 | +- No sentinel behavioral monitor (Phase 2a) |
| 155 | +- No additional chat connectors beyond Telegram (Phase 2a) |
| 156 | +- No ATProto bridge (Phase 2a) |
| 157 | +- No Rekindle bridge (Phase 3) |
0 commit comments