diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 0e1ec62..2e4edba 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.28" +version = "0.0.29" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.28" +version = "0.0.29" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.28" +version = "0.0.29" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.28" +version = "0.0.29" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.28" +version = "0.0.29" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 6c47e5c..8c962bf 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.28" +version = "0.0.29" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 171de5d..fdea9bb 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.28" +version = "0.0.29" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/botfather.rs b/warzone/crates/warzone-server/src/botfather.rs new file mode 100644 index 0000000..9d7ee9d --- /dev/null +++ b/warzone/crates/warzone-server/src/botfather.rs @@ -0,0 +1,280 @@ +//! Built-in BotFather: processes messages to @botfather and manages bot lifecycle. +//! +//! Supports: /start, /newbot, /mybots, /deletebot, /help +//! Runs as a server-side handler — no external process needed. + +use crate::state::AppState; + +const BOTFATHER_FP: &str = "0000000000000000botfather00000000"; + +/// Check if a message is destined for BotFather and handle it. +/// Called from deliver_or_queue when the recipient is the BotFather fingerprint. +/// Returns true if handled (message consumed). +pub async fn handle_botfather_message(state: &AppState, from_fp: &str, message: &[u8]) -> bool { + if !state.bots_enabled { + return false; + } + + // Try to parse as plaintext bot_message JSON + let bot_msg: serde_json::Value = match serde_json::from_slice(message) { + Ok(v) => v, + Err(_) => return false, // Encrypted messages can't be processed by built-in handler + }; + + if bot_msg.get("type").and_then(|v| v.as_str()) != Some("bot_message") { + return false; + } + + let text = bot_msg + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let from_name = bot_msg + .get("from_name") + .and_then(|v| v.as_str()) + .unwrap_or(from_fp); + + tracing::info!( + "BotFather: message from {} ({}): {}", + from_fp, + from_name, + text + ); + + let response = match text { + "/start" | "/help" => { + "Welcome to BotFather! I can help you create and manage bots.\n\n\ + Commands:\n\ + /newbot - Create a new bot\n\ + /mybots - List your bots\n\ + /deletebot - Delete a bot\n\ + /token - Get bot token\n\ + /help - Show this message" + .to_string() + } + t if t.starts_with("/newbot") => handle_newbot(state, from_fp, t).await, + t if t.starts_with("/deletebot") => handle_deletebot(state, from_fp, t).await, + "/mybots" => handle_mybots(state, from_fp).await, + t if t.starts_with("/token") => handle_token(state, from_fp, t).await, + _ => "I don't understand that command. Try /help".to_string(), + }; + + // Send response back to the user + send_botfather_reply(state, from_fp, &response).await; + true +} + +async fn handle_newbot(state: &AppState, owner_fp: &str, text: &str) -> String { + // Parse: /newbot + let name = text.strip_prefix("/newbot").unwrap_or("").trim(); + if name.is_empty() { + return "Usage: /newbot \n\nExample: /newbot WeatherBot\n\n\ + The name must end with 'bot' or 'Bot'." + .to_string(); + } + + // Validate name + if name.len() > 32 || name.len() < 3 { + return "Bot name must be 3-32 characters.".to_string(); + } + + let name_lower = name.to_lowercase(); + if !name_lower.ends_with("bot") { + return "Bot name must end with 'bot' or 'Bot'. Example: WeatherBot, my_bot".to_string(); + } + + // Check if alias is taken + let alias_key = format!("a:{}", name_lower); + if state + .db + .aliases + .get(alias_key.as_bytes()) + .ok() + .flatten() + .is_some() + { + return format!( + "Sorry, @{} is already taken. Try a different name.", + name_lower + ); + } + + // Generate fingerprint and token + let fp_bytes: [u8; 16] = rand::random(); + let fp = hex::encode(fp_bytes); + let token_rand: [u8; 16] = rand::random(); + let token = format!("{}:{}", &fp[..16], hex::encode(token_rand)); + + // Store bot info + let bot_info = serde_json::json!({ + "name": name, + "fingerprint": fp, + "token": token, + "owner": owner_fp, + "e2e": false, + "created_at": chrono::Utc::now().timestamp(), + }); + + let bot_key = format!("bot:{}", token); + let _ = state.db.tokens.insert( + bot_key.as_bytes(), + serde_json::to_vec(&bot_info).unwrap_or_default(), + ); + let fp_key = format!("bot_fp:{}", fp); + let _ = state.db.tokens.insert(fp_key.as_bytes(), token.as_bytes()); + + // Register alias + let _ = state + .db + .aliases + .insert(alias_key.as_bytes(), fp.as_bytes()); + let _ = state + .db + .aliases + .insert(format!("fp:{}", fp).as_bytes(), name_lower.as_bytes()); + + tracing::info!( + "BotFather: created bot @{} for owner {}", + name_lower, + owner_fp + ); + + format!( + "Done! Your new bot @{} is ready.\n\n\ + Token: {}\n\n\ + Keep this token secret! Use it to access the Bot API.\n\n\ + API endpoint: /v1/bot/{}/getUpdates", + name_lower, token, token + ) +} + +async fn handle_deletebot(state: &AppState, owner_fp: &str, text: &str) -> String { + let name = text + .strip_prefix("/deletebot") + .unwrap_or("") + .trim() + .to_lowercase(); + if name.is_empty() { + return "Usage: /deletebot ".to_string(); + } + + // Find the bot + let alias_key = format!("a:{}", name); + let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Bot @{} not found.", name), + }; + + // Get bot info to verify ownership + let token_key = format!("bot_fp:{}", bot_fp); + let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Bot @{} not found in registry.", name), + }; + + let bot_key = format!("bot:{}", token); + if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() { + if let Ok(info) = serde_json::from_slice::(&info_bytes) { + let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or(""); + if owner != owner_fp && owner != "system" { + return format!("You don't own @{}. Only the owner can delete it.", name); + } + } + } + + // Delete everything + let _ = state.db.tokens.remove(bot_key.as_bytes()); + let _ = state.db.tokens.remove(token_key.as_bytes()); + let _ = state.db.aliases.remove(alias_key.as_bytes()); + let _ = state + .db + .aliases + .remove(format!("fp:{}", bot_fp).as_bytes()); + let _ = state.db.keys.remove(bot_fp.as_bytes()); + + tracing::info!("BotFather: deleted bot @{} by owner {}", name, owner_fp); + format!("Bot @{} has been deleted.", name) +} + +async fn handle_mybots(state: &AppState, owner_fp: &str) -> String { + let mut bots = Vec::new(); + + for item in state.db.tokens.iter().flatten() { + let key = String::from_utf8_lossy(&item.0).to_string(); + if !key.starts_with("bot:") { + continue; + } + if let Ok(info) = serde_json::from_slice::(&item.1) { + let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or(""); + if owner == owner_fp { + let name = info.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let e2e = info.get("e2e").and_then(|v| v.as_bool()).unwrap_or(false); + let mode = if e2e { "E2E" } else { "plaintext" }; + bots.push(format!(" @{} ({})", name.to_lowercase(), mode)); + } + } + } + + if bots.is_empty() { + "You have no bots. Use /newbot to create one.".to_string() + } else { + format!("Your bots ({}):\n{}", bots.len(), bots.join("\n")) + } +} + +async fn handle_token(state: &AppState, owner_fp: &str, text: &str) -> String { + let name = text + .strip_prefix("/token") + .unwrap_or("") + .trim() + .to_lowercase(); + if name.is_empty() { + return "Usage: /token ".to_string(); + } + + let alias_key = format!("a:{}", name); + let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Bot @{} not found.", name), + }; + + let token_key = format!("bot_fp:{}", bot_fp); + let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Token not found for @{}.", name), + }; + + // Verify ownership + let bot_key = format!("bot:{}", token); + if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() { + if let Ok(info) = serde_json::from_slice::(&info_bytes) { + let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or(""); + if owner != owner_fp { + return format!("You don't own @{}.", name); + } + } + } + + format!("Token for @{}:\n{}", name, token) +} + +/// Send a reply from BotFather to a user. +async fn send_botfather_reply(state: &AppState, to_fp: &str, text: &str) { + let msg = serde_json::json!({ + "type": "bot_message", + "id": uuid::Uuid::new_v4().to_string(), + "from": BOTFATHER_FP, + "from_name": "BotFather", + "text": text, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&msg).unwrap_or_default(); + + // Deliver directly (don't go through deliver_or_queue to avoid recursion) + if !state.push_to_client(to_fp, &msg_bytes).await { + // Queue for offline pickup + let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4()); + let _ = state.db.messages.insert(key.as_bytes(), msg_bytes); + } +} diff --git a/warzone/crates/warzone-server/src/lib.rs b/warzone/crates/warzone-server/src/lib.rs index e852ef1..9da2ec7 100644 --- a/warzone/crates/warzone-server/src/lib.rs +++ b/warzone/crates/warzone-server/src/lib.rs @@ -1,4 +1,5 @@ pub mod auth_middleware; +pub mod botfather; pub mod config; pub mod db; pub mod errors; diff --git a/warzone/crates/warzone-server/src/main.rs b/warzone/crates/warzone-server/src/main.rs index 55b5335..55c07cf 100644 --- a/warzone/crates/warzone-server/src/main.rs +++ b/warzone/crates/warzone-server/src/main.rs @@ -1,5 +1,6 @@ use clap::Parser; +mod botfather; pub mod auth_middleware; mod config; mod db; diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index af8e3c0..5d6276b 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v10'; +const CACHE = 'wz-v11'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -241,7 +241,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.28'; +const VERSION = '0.0.29'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── diff --git a/warzone/crates/warzone-server/src/state.rs b/warzone/crates/warzone-server/src/state.rs index d12ec90..d5ff857 100644 --- a/warzone/crates/warzone-server/src/state.rs +++ b/warzone/crates/warzone-server/src/state.rs @@ -173,6 +173,19 @@ impl AppState { /// Try to deliver a message: local push → federation forward → DB queue. /// Returns true if delivered instantly (local or remote). pub async fn deliver_or_queue(&self, to_fp: &str, message: &[u8]) -> bool { + // BotFather: intercept messages to @botfather + if self.bots_enabled && to_fp == "0000000000000000botfather00000000" { + // Extract sender from message + if let Ok(msg) = serde_json::from_slice::(message) { + let from = msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); + if !from.is_empty() { + if crate::botfather::handle_botfather_message(self, from, message).await { + return true; + } + } + } + } + // 1. Try local WebSocket push if self.push_to_client(to_fp, message).await { return true; diff --git a/warzone/tools/botfather.py b/warzone/tools/botfather.py new file mode 100755 index 0000000..4de1c17 --- /dev/null +++ b/warzone/tools/botfather.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +featherChat BotFather (Standalone) + +A Telegram-style BotFather that manages bot creation via chat. +Uses the featherChat Bot API — runs as a regular bot process. + +Usage: + python botfather.py --server http://localhost:7700 + +On first run, it registers itself as @botfather if not already registered. +Subsequent runs reuse the stored token from .botfather_token file. + +Commands: + /start, /help - Show help + /newbot - Create a new bot (name must end with bot/Bot) + /mybots - List your bots + /deletebot - Delete a bot you own + /token - Show token for your bot +""" + +import argparse +import json +import os +import sys +import time +from urllib.request import Request, urlopen +from urllib.error import URLError + +TOKEN_FILE = ".botfather_token" + + +def api(server, token, method, data=None): + """Call a bot API method.""" + url = f"{server}/v1/bot/{token}/{method}" + body = json.dumps(data).encode() if data else None + req = Request(url, data=body, method="POST" if body else "GET") + req.add_header("Content-Type", "application/json") + try: + with urlopen(req, timeout=60) as resp: + return json.loads(resp.read()) + except URLError as e: + print(f"API error ({method}): {e}") + return {"ok": False} + + +def send(server, token, chat_id, text): + """Send a message.""" + return api(server, token, "sendMessage", {"chat_id": chat_id, "text": text}) + + +def register_botfather(server): + """Register BotFather with the server. Returns token.""" + # BotFather registers itself — it needs the built-in BotFather token + # to authorize. Read it from the server's initial log or pass via env. + builtin_token = os.environ.get("BOTFATHER_TOKEN", "") + if not builtin_token: + print("ERROR: Set BOTFATHER_TOKEN env var to the token from server logs") + print(" (printed on first --enable-bots start)") + sys.exit(1) + + # Use the built-in token directly + return builtin_token + + +def handle_message(server, token, msg): + """Process a message and respond.""" + text = (msg.get("text") or "").strip() + chat_id = msg.get("chat", {}).get("id", "") + from_id = msg.get("from", {}).get("id_str") or str(msg.get("from", {}).get("id", "")) + + if not text or not chat_id: + return + + print(f"[{from_id[:16]}] {text}") + + if text in ("/start", "/help"): + send(server, token, chat_id, + "Welcome to BotFather! I manage bots on featherChat.\n\n" + "Commands:\n" + "/newbot - Create a bot (name must end with bot/Bot)\n" + "/mybots - List your bots\n" + "/deletebot - Delete your bot\n" + "/token - Get bot token\n" + "/help - Show this message") + + elif text.startswith("/newbot"): + name = text.replace("/newbot", "").strip() + if not name: + send(server, token, chat_id, "Usage: /newbot \nExample: /newbot WeatherBot") + return + + if len(name) < 3 or len(name) > 32: + send(server, token, chat_id, "Bot name must be 3-32 characters.") + return + + if not name.lower().endswith("bot"): + send(server, token, chat_id, "Bot name must end with 'bot' or 'Bot'.") + return + + # Create the bot via internal API + fp = os.urandom(16).hex() + resp = api(server, token, "../register", { + "name": name, + "fingerprint": fp, + "botfather_token": token, + "owner": from_id + }) + + if resp.get("ok"): + result = resp["result"] + send(server, token, chat_id, + f"Done! Your new bot @{result.get('alias', name.lower())} is ready.\n\n" + f"Token: {result['token']}\n\n" + f"Keep this token secret!") + else: + send(server, token, chat_id, f"Failed: {resp.get('description', 'unknown error')}") + + elif text == "/mybots": + send(server, token, chat_id, + "Use the built-in /mybots via chat with @botfather.\n" + "(The built-in handler tracks ownership.)") + + elif text.startswith("/deletebot"): + name = text.replace("/deletebot", "").strip() + if not name: + send(server, token, chat_id, "Usage: /deletebot ") + return + send(server, token, chat_id, + f"Use the built-in /deletebot {name} via chat with @botfather.\n" + "(The built-in handler verifies ownership.)") + + elif text.startswith("/token"): + name = text.replace("/token", "").strip() + if not name: + send(server, token, chat_id, "Usage: /token ") + return + send(server, token, chat_id, + f"Use the built-in /token {name} via chat with @botfather.\n" + "(The built-in handler verifies ownership.)") + + else: + send(server, token, chat_id, "Unknown command. Try /help") + + +def main(): + parser = argparse.ArgumentParser(description="featherChat BotFather (standalone)") + parser.add_argument("--server", required=True, help="featherChat server URL") + parser.add_argument("--token", help="BotFather token (or set BOTFATHER_TOKEN env)") + args = parser.parse_args() + + token = args.token or os.environ.get("BOTFATHER_TOKEN", "") + + # Try loading from file + if not token and os.path.exists(TOKEN_FILE): + token = open(TOKEN_FILE).read().strip() + + if not token: + token = register_botfather(args.server) + + # Save token + with open(TOKEN_FILE, "w") as f: + f.write(token) + + # Verify + me = api(args.server, token, "getMe") + if not me.get("ok"): + print(f"ERROR: Invalid token. Delete {TOKEN_FILE} and retry.") + sys.exit(1) + + bot_name = me["result"].get("first_name", "BotFather") + print(f"BotFather ({bot_name}) running") + print(f"Server: {args.server}") + print(f"Polling for messages...") + print() + + offset = 0 + while True: + try: + resp = api(args.server, token, "getUpdates", {"offset": offset, "timeout": 30}) + for update in resp.get("result", []): + offset = update["update_id"] + 1 + msg = update.get("message", {}) + if msg: + handle_message(args.server, token, msg) + except KeyboardInterrupt: + print("\nBotFather stopped.") + break + except Exception as e: + print(f"Error: {e}") + time.sleep(3) + + +if __name__ == "__main__": + main()