//! Telegram bots as a chat interface. Every user can connect their own bot (token from //! @BotFather) and pair it with their Telegram account; then plain messages go to the //! current session, commands switch sessions, modes and models, and the agent's progress, //! answers, images, permission requests and questions come back to Telegram. //! //! The server polls Telegram (getUpdates), so no public webhook is needed. Only the paired //! chat is served; messages from anywhere else are ignored. use crate::app::{App, PermissionReply}; use crate::store::User; use crate::types::{Event, ImageData, Message, Part, Role, Session, new_id, now_ms}; use anyhow::{Context, Result, anyhow, bail}; use base64::Engine; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, LazyLock, Mutex}; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; /// User setting key. const KEY: &str = "telegram"; /// Telegram's message limit is 4096 characters; leave room for HTML tags. const CHUNK: usize = 3500; /// Edits of the live progress message at most this often (Telegram rate limits). const EDIT_EVERY: Duration = Duration::from_millis(1500); /// Files sent to the bot are saved here, relative to the session folder. const UPLOADS: &str = ".codecraft/uploads"; /// What a user configured (stored in the user's settings). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Settings { #[serde(default)] pub token: String, /// Bot username from getMe. #[serde(default)] pub bot: String, /// The paired Telegram chat (only this one is served). #[serde(default)] pub chat_id: Option, /// One-time code for `/start ` until the chat is paired. #[serde(default)] pub pair_code: String, /// The session plain messages go to. #[serde(default)] pub session: Option, /// Sessions of the last /sessions list (for `/session `). #[serde(default)] pub list: Vec, /// Reasoning effort for the next messages (/effort). #[serde(default)] pub effort: Option, } fn api_base() -> String { std::env::var("CODECRAFT_TELEGRAM_API").unwrap_or_else(|_| "https://api.telegram.org".into()).trim_end_matches('/').to_string() } pub fn load(app: &App, user_id: &str) -> Settings { app.store.user_setting(user_id, KEY).unwrap_or_default() } fn save(app: &App, user_id: &str, s: &Settings) { let _ = app.store.set_user_setting(user_id, KEY, s); } /// Change the stored settings atomically (the poller and the listener both write). fn update(app: &App, user_id: &str, f: impl FnOnce(&mut Settings)) -> Settings { static LOCK: Mutex<()> = Mutex::new(()); let _g = LOCK.lock().unwrap(); let mut s = load(app, user_id); f(&mut s); save(app, user_id, &s); s } // --- Telegram Bot API --------------------------------------------------------------- #[derive(Clone)] struct Bot { token: String, } impl Bot { fn url(&self, method: &str) -> String { format!("{}/bot{}/{method}", api_base(), self.token) } async fn call(&self, method: &str, body: Value) -> Result { let resp = crate::provider::http_client().post(self.url(method)).timeout(Duration::from_secs(60)).json(&body).send().await.context("telegram request failed")?; let status = resp.status(); let v: Value = resp.json().await.unwrap_or_else(|_| json!({})); if v["ok"].as_bool() != Some(true) { bail!("telegram {method}: HTTP {status}: {}", v["description"].as_str().unwrap_or("error")); } Ok(v["result"].clone()) } async fn send(&self, chat: i64, html: &str, keyboard: Option) -> Result { let mut body = json!({"chat_id": chat, "text": html, "parse_mode": "HTML", "link_preview_options": {"is_disabled": true}}); if let Some(k) = keyboard { body["reply_markup"] = json!({"inline_keyboard": k}); } let r = match self.call("sendMessage", body.clone()).await { Ok(r) => r, // Broken HTML (should not happen): send as plain text instead of losing it. Err(e) if e.to_string().contains("parse") => { body.as_object_mut().unwrap().remove("parse_mode"); body["text"] = json!(strip_tags(html)); self.call("sendMessage", body).await? } Err(e) => return Err(e), }; Ok(r["message_id"].as_i64().unwrap_or(0)) } async fn edit(&self, chat: i64, message: i64, html: &str, keyboard: Option) -> Result<()> { let mut body = json!({"chat_id": chat, "message_id": message, "text": html, "parse_mode": "HTML", "link_preview_options": {"is_disabled": true}}); body["reply_markup"] = json!({"inline_keyboard": keyboard.unwrap_or_else(|| json!([]))}); match self.call("editMessageText", body).await { Err(e) if e.to_string().contains("not modified") => Ok(()), r => r.map(|_| ()), } } async fn send_photo(&self, chat: i64, image: &ImageData, caption: Option<&str>) -> Result<()> { let bytes = base64::engine::general_purpose::STANDARD.decode(&image.data)?; let ext = image.media_type.rsplit('/').next().unwrap_or("png").to_string(); let part = reqwest::multipart::Part::bytes(bytes).file_name(format!("image.{ext}")).mime_str(&image.media_type)?; let mut form = reqwest::multipart::Form::new().text("chat_id", chat.to_string()).part("photo", part); if let Some(c) = caption { form = form.text("caption", c.chars().take(1000).collect::()); } let v: Value = crate::provider::http_client().post(self.url("sendPhoto")).timeout(Duration::from_secs(120)).multipart(form).send().await?.json().await?; if v["ok"].as_bool() != Some(true) { bail!("telegram sendPhoto: {}", v["description"].as_str().unwrap_or("error")); } Ok(()) } /// Download a file the user sent (photo or document). async fn download(&self, file_id: &str) -> Result<(Vec, String)> { let f = self.call("getFile", json!({"file_id": file_id})).await?; let path = f["file_path"].as_str().context("telegram file without path")?.to_string(); let url = format!("{}/file/bot{}/{path}", api_base(), self.token); let bytes = crate::provider::http_client().get(url).timeout(Duration::from_secs(120)).send().await?.error_for_status()?.bytes().await?; Ok((bytes.to_vec(), path)) } } /// Find the bot token in what the user pasted (also the whole BotFather message). pub fn extract_token(input: &str) -> Option { let re = regex::Regex::new(r"\b(\d{5,}:[A-Za-z0-9_-]{30,})\b").unwrap(); re.captures(input).map(|c| c[1].to_string()) } /// Check a token and return the bot's username. pub async fn check_token(token: &str) -> Result { let me = Bot { token: token.to_string() }.call("getMe", json!({})).await?; me["username"].as_str().map(String::from).context("getMe returned no username") } // --- running bots ----------------------------------------------------------------------- struct Running { cancel: CancellationToken, token: String, } static RUNNING: LazyLock>> = LazyLock::new(Default::default); /// Last error per user (shown in the settings). static ERRORS: LazyLock>> = LazyLock::new(Default::default); pub fn status(user_id: &str) -> (bool, Option) { (RUNNING.lock().unwrap().contains_key(user_id), ERRORS.lock().unwrap().get(user_id).cloned()) } /// Start (or restart) the bot of a user according to their settings. pub fn restart(app: &Arc, user_id: &str) { stop(user_id); let s = load(app, user_id); if s.token.is_empty() { return; } let cancel = CancellationToken::new(); RUNNING.lock().unwrap().insert(user_id.to_string(), Running { cancel: cancel.clone(), token: s.token.clone() }); ERRORS.lock().unwrap().remove(user_id); let (app, uid) = (app.clone(), user_id.to_string()); tokio::spawn(async move { poll(app, uid, Bot { token: s.token }, cancel).await }); } pub fn stop(user_id: &str) { if let Some(r) = RUNNING.lock().unwrap().remove(user_id) { r.cancel.cancel(); } } /// Start all configured bots and the event listener (at server start). pub fn spawn(app: Arc) { for u in app.store.users().unwrap_or_default() { if !u.disabled && !load(&app, &u.id).token.is_empty() { restart(&app, &u.id); } } tokio::spawn(listen(app)); } fn bot_of(user_id: &str) -> Option { RUNNING.lock().unwrap().get(user_id).map(|r| Bot { token: r.token.clone() }) } async fn poll(app: Arc, user_id: String, bot: Bot, cancel: CancellationToken) { let mut offset: i64 = 0; let mut backoff = 2u64; loop { let req = bot.call("getUpdates", json!({"offset": offset, "timeout": 30, "allowed_updates": ["message", "callback_query"]})); let updates = tokio::select! { _ = cancel.cancelled() => return, r = req => r, }; let updates = match updates { Ok(u) => { backoff = 2; ERRORS.lock().unwrap().remove(&user_id); u } Err(e) => { let msg = format!("{e:#}"); tracing::warn!(user = %user_id, "telegram: {msg}"); ERRORS.lock().unwrap().insert(user_id.clone(), msg.clone()); if msg.contains("HTTP 401") || msg.contains("HTTP 404") { // Token revoked: stop instead of hammering the API. RUNNING.lock().unwrap().remove(&user_id); return; } tokio::select! { _ = cancel.cancelled() => return, _ = tokio::time::sleep(Duration::from_secs(backoff)) => {} } backoff = (backoff * 2).min(60); continue; } }; for u in updates.as_array().into_iter().flatten() { offset = offset.max(u["update_id"].as_i64().unwrap_or(0) + 1); if let Err(e) = handle_update(&app, &user_id, &bot, u).await { tracing::warn!(user = %user_id, "telegram update: {e:#}"); if let Some(chat) = load(&app, &user_id).chat_id { let _ = bot.send(chat, &esc(&format!("{e:#}")), None).await; } } } } } // --- incoming messages --------------------------------------------------------------- fn user(app: &App, user_id: &str) -> Result { app.store.user(user_id)?.filter(|u| !u.disabled).ok_or_else(|| anyhow!("account disabled")) } /// The user reads German (language preference of the web UI). pub fn german(app: &App, user_id: &str) -> bool { let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default(); prefs["lang"].as_str().unwrap_or("en").starts_with("de") } /// Pick the German or the English text. fn tr<'a>(de: bool, de_text: &'a str, en_text: &'a str) -> &'a str { if de { de_text } else { en_text } } async fn handle_update(app: &Arc, user_id: &str, bot: &Bot, u: &Value) -> Result<()> { let s = load(app, user_id); if let Some(q) = u.get("callback_query") { let chat = q["message"]["chat"]["id"].as_i64().unwrap_or(0); let _ = bot.call("answerCallbackQuery", json!({"callback_query_id": q["id"]})).await; if Some(chat) != s.chat_id { return Ok(()); } return callback(app, user_id, bot, chat, q["data"].as_str().unwrap_or(""), q["message"]["message_id"].as_i64()).await; } let Some(m) = u.get("message") else { return Ok(()) }; let chat = m["chat"]["id"].as_i64().unwrap_or(0); let text = m["text"].as_str().or(m["caption"].as_str()).unwrap_or("").trim().to_string(); let de = german(app, user_id); // Pairing: the first chat that sends the code becomes the only one served. if s.chat_id.is_none() { let code = text.strip_prefix("/start").map(str::trim).unwrap_or(""); if !s.pair_code.is_empty() && code == s.pair_code && m["chat"]["type"] == "private" { update(app, user_id, |s| { s.chat_id = Some(chat); s.pair_code.clear(); }); let u = user(app, user_id)?; bot.send(chat, &esc(&format!("{} {}. /help", tr(de, "Verbunden mit CodeCraft als", "Connected to CodeCraft as"), u.username)), None).await?; } else if text.starts_with("/start") { bot.send(chat, tr(de, "Bitte den Code aus CodeCraft (Einstellungen → Konto → Telegram) mitsenden: /start CODE", "Please send the code from CodeCraft (Settings → Account → Telegram): /start CODE"), None).await?; } return Ok(()); } if Some(chat) != s.chat_id { return Ok(()); // Not the paired chat. } let u = user(app, user_id)?; // A pending question of the current session takes a typed answer. if !text.starts_with('/') && !text.is_empty() { if let Some(sid) = &s.session { if let Some((qid, question)) = pending_question(app, sid) { let mut answers = Map::new(); answers.insert(question, json!(text)); app.answer_question(&qid, Some(answers))?; return Ok(()); } } } if let Some(cmd) = text.strip_prefix('/') { let (name, arg) = cmd.split_once(char::is_whitespace).map(|(a, b)| (a, b.trim())).unwrap_or((cmd, "")); let name = name.split('@').next().unwrap_or(name).to_lowercase(); match name.as_str() { "start" | "help" => return bot.send(chat, &help(de), None).await.map(|_| ()), "sessions" | "resume" => return list_sessions(app, user_id, bot, chat, de).await, "clear" => { let cur = current(app, &u, de)?; let folder = cur.cwd.clone(); return new_session(app, &u, bot, chat, &format!("{folder} {}", cur.agent), de).await; } "permissions" => return set_permissions(app, &u, bot, chat, arg, de).await, "prompt" => return chat_instructions(app, &u, bot, chat, arg, de).await, "effort" => return set_effort(app, &u, bot, chat, arg, de).await, "costs" => return costs(app, &u, bot, chat, de).await, "memory" => return memory(app, &u, bot, chat, de).await, "jobs" => return jobs(app, &u, bot, chat, de).await, "session" => return switch_session(app, &u, bot, chat, arg, de).await, "new" => return new_session(app, &u, bot, chat, arg, de).await, "mode" => return set_mode(app, &u, bot, chat, arg, de).await, "model" => return set_model(app, &u, bot, chat, arg, de).await, "stop" => { let sid = current(app, &u, de)?; crate::runner::abort(app, &sid.id); return bot.send(chat, tr(de, "Abgebrochen.", "Stopped."), None).await.map(|_| ()); } "status" => return status_message(app, &u, bot, chat, de).await, _ => {} // /compact and slash commands of the session go through as a prompt. } } // A message for the current session (with photos and files). let session = current(app, &u, de)?; let mut images = Vec::new(); let mut notes = Vec::new(); if let Some(photo) = m["photo"].as_array().and_then(|p| p.last()) { let (bytes, _) = bot.download(photo["file_id"].as_str().unwrap_or("")).await?; images.push(ImageData { blob: None, size: None, media_type: "image/jpeg".into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) }); } if let Some(doc) = m.get("document") { let mime = doc["mime_type"].as_str().unwrap_or(""); let (bytes, path) = bot.download(doc["file_id"].as_str().unwrap_or("")).await?; if mime.starts_with("image/") && bytes.len() < 10 * 1024 * 1024 { images.push(ImageData { blob: None, size: None, media_type: mime.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) }); } else { let name = doc["file_name"].as_str().map(String::from).unwrap_or_else(|| path.rsplit('/').next().unwrap_or("file").to_string()); let rel = save_upload(&session, &name, &bytes)?; notes.push(format!("Attached file: {rel}")); } } let mut prompt = text; if !notes.is_empty() { prompt = if prompt.is_empty() { notes.join("\n") } else { format!("{prompt}\n\n{}", notes.join("\n")) }; } if prompt.is_empty() && images.is_empty() { return Ok(()); } let effort = load(app, user_id).effort; let r = crate::api::send_prompt(app, session, prompt, images, effort.as_deref()).map_err(|e| anyhow!("{}", e.1))?; if r.get("queued").is_some() { bot.send(chat, tr(de, "Eingereiht – wird beim nächsten Schritt übergeben.", "Queued – delivered at the agent's next step."), None).await?; } Ok(()) } /// Save a file into the session folder (a free name in .codecraft/uploads). fn save_upload(session: &Session, name: &str, bytes: &[u8]) -> Result { let clean: String = name.chars().map(|c| if c.is_alphanumeric() || ".-_".contains(c) { c } else { '_' }).collect(); let clean = if clean.trim_matches('.').is_empty() { "file".to_string() } else { clean }; let dir = PathBuf::from(&session.cwd).join(UPLOADS); std::fs::create_dir_all(&dir)?; let mut path = dir.join(&clean); let mut n = 1; while path.exists() { n += 1; path = dir.join(format!("{n}-{clean}")); } std::fs::write(&path, bytes)?; Ok(format!("{UPLOADS}/{}", path.file_name().unwrap().to_string_lossy())) } fn help(de: bool) -> String { if de { "CodeCraft – Nachrichten gehen an die aktuelle Sitzung (Fotos und Dateien auch).\n\n\ /sessions – letzte Sitzungen\n/session <Nr|Name> – Sitzung wechseln\n/new [Ordner] [Modus] – neue Sitzung (ohne Ordner: Chat)\n\ /clear – neue Sitzung im gleichen Ordner\n/mode [Name] – Modus\n/model [Modell] – Modell\n/permissions [Modus] – Freigaben\n/prompt [Text|-] – Anweisungen für diesen Chat\n/effort [Stufe] – Denktiefe\n\ /stop – Runde abbrechen\n/status – aktuelle Sitzung\n/costs – Kosten und Budgets\n/memory – Gedächtnis\n/jobs – Automationen\n/compact – Unterhaltung komprimieren" .into() } else { "CodeCraft – messages go to the current session (photos and files too).\n\n\ /sessions – recent sessions\n/session <n|name> – switch session\n/new [folder] [mode] – new session (no folder: chat)\n\ /clear – new session in the same folder\n/mode [name] – mode\n/model [model] – model\n/permissions [mode] – approvals\n/prompt [text|-] – instructions for this chat\n/effort [level] – reasoning effort\n\ /stop – stop the turn\n/status – current session\n/costs – costs and budgets\n/memory – memory\n/jobs – automations\n/compact – compact the conversation" .into() } } /// The current session, or the user's most recent one. fn current(app: &App, u: &User, de: bool) -> Result { let s = load(app, &u.id); if let Some(sess) = s.session.as_deref().and_then(|id| app.store.get_session(id).ok().flatten()).filter(|x| x.owner_id.as_deref() == Some(u.id.as_str())) { return Ok(sess); } let recent = own_sessions(app, &u.id).into_iter().next().ok_or_else(|| anyhow!("{}", tr(de, "Noch keine Sitzung – /new startet eine.", "No session yet – /new starts one.")))?; update(app, &u.id, |s| s.session = Some(recent.id.clone())); Ok(recent) } fn own_sessions(app: &App, user_id: &str) -> Vec { app.store.list_sessions(Some(user_id)).unwrap_or_default().into_iter().filter(|s| s.owner_id.as_deref() == Some(user_id) && !s.archived).collect() } fn label(s: &Session) -> String { let title = if s.title.is_empty() { "(untitled)".to_string() } else { s.title.clone() }; let folder = s.cwd.rsplit('/').next().unwrap_or(""); format!("{} · {folder}", title.chars().take(40).collect::()) } async fn list_sessions(app: &App, user_id: &str, bot: &Bot, chat: i64, de: bool) -> Result<()> { let list: Vec = own_sessions(app, user_id).into_iter().take(10).collect(); if list.is_empty() { bot.send(chat, tr(de, "Noch keine Sitzungen – /new startet eine.", "No sessions yet – /new starts one."), None).await?; return Ok(()); } let cur = load(app, user_id).session; let keyboard: Vec = list .iter() .enumerate() .map(|(i, s)| { let mark = if cur.as_deref() == Some(s.id.as_str()) { "• " } else { "" }; json!([{"text": format!("{mark}{} {}", i + 1, label(s)), "callback_data": format!("s:{}", s.id)}]) }) .collect(); update(app, user_id, |st| st.list = list.iter().map(|s| s.id.clone()).collect()); bot.send(chat, tr(de, "Sitzung wählen:", "Pick a session:"), Some(json!(keyboard))).await?; Ok(()) } async fn announce(app: &App, bot: &Bot, chat: i64, s: &Session, de: bool) -> Result<()> { let mut text = format!("{}\n{} · {} · {}", esc(&label(s)), esc(&s.cwd), esc(&s.agent), esc(&s.model)); // The last answer, to pick up the thread. if let Some(last) = app.store.messages(&s.id).unwrap_or_default().iter().rev().find(|m| m.role == Role::Assistant).map(text_of).filter(|t| !t.is_empty()) { let short: String = last.chars().take(600).collect(); text.push_str(&format!("\n\n{}{}", md_to_html(&short), if last.chars().count() > 600 { " …" } else { "" })); } let _ = de; bot.send(chat, &text, None).await?; Ok(()) } async fn switch_session(app: &App, u: &User, bot: &Bot, chat: i64, arg: &str, de: bool) -> Result<()> { if arg.is_empty() { return list_sessions(app, &u.id, bot, chat, de).await; } let st = load(app, &u.id); let own = own_sessions(app, &u.id); let found = match arg.parse::() { Ok(n) if n >= 1 => st.list.get(n - 1).and_then(|id| own.iter().find(|s| &s.id == id)).cloned(), _ => { let q = arg.to_lowercase(); own.iter().find(|s| s.title.to_lowercase().contains(&q) || s.cwd.to_lowercase().ends_with(&q)).cloned() } }; let Some(s) = found else { bot.send(chat, tr(de, "Keine passende Sitzung – /sessions zeigt die letzten.", "No matching session – /sessions lists the recent ones."), None).await?; return Ok(()); }; update(app, &u.id, |st| st.session = Some(s.id.clone())); announce(app, bot, chat, &s, de).await } async fn new_session(app: &Arc, u: &User, bot: &Bot, chat: i64, arg: &str, de: bool) -> Result<()> { let roots = app.user_roots(Some(&u.id)); let root = roots.first().cloned().ok_or_else(|| anyhow!("no folder available"))?; let words: Vec<&str> = arg.split_whitespace().collect(); let agents = crate::prompts::agents_for(app, &root, &root, Some(&u.id)); let is_mode = |w: &str| agents.iter().any(|a| a.name == w && a.mode == "primary"); let (folder, mode) = match words.as_slice() { [] => (None, "chat".to_string()), [m] if is_mode(m) => (None, m.to_string()), [f] => (Some(*f), "build".to_string()), [f, m, ..] => (Some(*f), m.to_string()), }; if !is_mode(&mode) { bot.send(chat, &esc(&format!("{}: {mode}", tr(de, "Unbekannter Modus", "Unknown mode"))), None).await?; return Ok(()); } let cwd = match folder { None => root.clone(), Some(f) => { let p = crate::tools::normalize(&if f.starts_with('/') { PathBuf::from(f) } else { root.join(f) }); if !roots.iter().any(|r| p.starts_with(r)) || !p.is_dir() { bot.send(chat, &esc(&format!("{}: {f}", tr(de, "Ordner nicht gefunden", "Folder not found"))), None).await?; return Ok(()); } p } }; let cfg = app.config(); let model = Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()).or_else(|| app.registry().first_model()).ok_or_else(|| anyhow!("no model configured"))?; let now = now_ms(); let s = app.save_session(Session { id: new_id(), title: String::new(), cwd: cwd.display().to_string(), model, agent: mode, permission_mode: "auto".into(), parent_id: None, owner_id: Some(u.id.clone()), created_at: now, updated_at: now, todos: Value::Null, last_usage: None, clean_room: None, phase: None, worktree: None, pinned: false, archived: false, folder: None, instructions: String::new(), // Started from Telegram: keeps reporting there after switching to another chat. telegram_quiet: false, })?; update(app, &u.id, |st| st.session = Some(s.id.clone())); bot.send(chat, &format!("{} {} · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?; Ok(()) } async fn set_mode(app: &App, u: &User, bot: &Bot, chat: i64, arg: &str, de: bool) -> Result<()> { let s = current(app, u, de)?; let cwd = PathBuf::from(&s.cwd); let root = app.root_for(Some(&u.id), &cwd); let modes: Vec = crate::prompts::agents_for(app, &root, &cwd, Some(&u.id)).into_iter().filter(|a| a.mode == "primary").map(|a| a.name).collect(); if arg.is_empty() { let keyboard: Vec = modes .chunks(3) .map(|row| json!(row.iter().map(|m| json!({"text": if *m == s.agent { format!("• {m}") } else { m.clone() }, "callback_data": format!("m:{m}")})).collect::>())) .collect(); bot.send(chat, tr(de, "Modus wählen:", "Pick a mode:"), Some(json!(keyboard))).await?; return Ok(()); } if !modes.iter().any(|m| m == arg) { bot.send(chat, &esc(&format!("{}: {arg}", tr(de, "Unbekannter Modus", "Unknown mode"))), None).await?; return Ok(()); } app.update_session(&s.id, |x| x.agent = arg.to_string())?; bot.send(chat, &format!("{}: {}", tr(de, "Modus", "Mode"), esc(arg)), None).await?; Ok(()) } /// All models as "provider/model". fn models(app: &App) -> Vec { let mut out = Vec::new(); for p in app.registry().models_json().as_array().into_iter().flatten() { for m in p["models"].as_array().into_iter().flatten() { out.push(format!("{}/{}", p["provider"].as_str().unwrap_or(""), m["id"].as_str().unwrap_or(""))); } } out } async fn set_model(app: &App, u: &User, bot: &Bot, chat: i64, arg: &str, de: bool) -> Result<()> { let s = current(app, u, de)?; let all = models(app); if arg.is_empty() { // Callback data is limited to 64 bytes: buttons carry the index. let keyboard: Vec = all .iter() .enumerate() .take(40) .map(|(i, m)| json!([{"text": if *m == s.model { format!("• {m}") } else { m.clone() }, "callback_data": format!("M:{i}")}])) .collect(); bot.send(chat, tr(de, "Modell wählen:", "Pick a model:"), Some(json!(keyboard))).await?; return Ok(()); } let pick = all.iter().find(|m| m.as_str() == arg).or_else(|| all.iter().find(|m| m.to_lowercase().contains(&arg.to_lowercase()))).cloned(); let Some(m) = pick else { bot.send(chat, &esc(&format!("{}: {arg}", tr(de, "Unbekanntes Modell", "Unknown model"))), None).await?; return Ok(()); }; app.update_session(&s.id, |x| x.model = m.clone())?; bot.send(chat, &format!("{}: {}", tr(de, "Modell", "Model"), esc(&m)), None).await?; Ok(()) } async fn status_message(app: &App, u: &User, bot: &Bot, chat: i64, de: bool) -> Result<()> { let s = current(app, u, de)?; let busy = app.is_busy(&s.id); let ctx = s.last_usage.as_ref().map(|x| x.context_tokens()).unwrap_or(0); let text = format!( "{}\n{}: {}\n{}: {}\n{}: {}\n{}: {}\n{}", esc(&label(&s)), tr(de, "Ordner", "Folder"), esc(&s.cwd), tr(de, "Modus", "Mode"), esc(&s.agent), tr(de, "Modell", "Model"), esc(&s.model), tr(de, "Kontext", "Context"), ctx, if busy { tr(de, "arbeitet …", "working …") } else { tr(de, "bereit", "idle") }, ); bot.send(chat, &text, None).await?; Ok(()) } /// Show, set ("/prompt text") or clear ("/prompt -") the instructions of the current chat. async fn chat_instructions(app: &App, u: &User, bot: &Bot, chat: i64, arg: &str, de: bool) -> Result<()> { let s = current(app, u, de)?; if arg.is_empty() { let text = if s.instructions.is_empty() { tr(de, "Keine Anweisungen für diesen Chat. Setzen mit /prompt <Text>.", "No instructions for this chat. Set them with /prompt <text>.").to_string() } else { format!("{}\n{}\n\n{}", tr(de, "Anweisungen für diesen Chat", "Instructions for this chat"), esc(&s.instructions), tr(de, "Löschen mit /prompt -", "Clear with /prompt -")) }; bot.send(chat, &text, None).await?; return Ok(()); } let new = if arg == "-" { String::new() } else { arg.to_string() }; app.update_session(&s.id, |x| x.instructions = new.clone())?; let text = if new.is_empty() { tr(de, "Anweisungen gelöscht.", "Instructions cleared.") } else { tr(de, "Anweisungen gesetzt – sie gelten ab der nächsten Nachricht.", "Instructions set – they apply from the next message.") }; bot.send(chat, text, None).await?; Ok(()) } const PERMISSION_MODES: &[&str] = &["auto", "default", "accept_edits", "bypass"]; const EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "max"]; /// Buttons in rows of three; the current value is marked. fn choice_keyboard(values: &[&str], current: &str, prefix: &str) -> Value { json!(values.chunks(3).map(|row| row.iter().map(|v| json!({"text": if *v == current { format!("• {v}") } else { v.to_string() }, "callback_data": format!("{prefix}:{v}")})).collect::>()).collect::>()) } async fn set_permissions(app: &App, u: &User, bot: &Bot, chat: i64, arg: &str, de: bool) -> Result<()> { let s = current(app, u, de)?; if arg.is_empty() { bot.send(chat, tr(de, "Freigabemodus wählen:", "Pick the approval mode:"), Some(choice_keyboard(PERMISSION_MODES, &s.permission_mode, "P"))).await?; return Ok(()); } if !PERMISSION_MODES.contains(&arg) { bot.send(chat, &esc(&format!("{}: {arg}", tr(de, "Unbekannter Modus", "Unknown mode"))), None).await?; return Ok(()); } app.update_session(&s.id, |x| x.permission_mode = arg.to_string())?; bot.send(chat, &format!("{}: {}", tr(de, "Freigaben", "Approvals"), esc(arg)), None).await?; Ok(()) } async fn set_effort(app: &App, u: &User, bot: &Bot, chat: i64, arg: &str, de: bool) -> Result<()> { let cur = load(app, &u.id).effort.unwrap_or_else(|| "auto".into()); if arg.is_empty() { let mut values = vec!["auto"]; values.extend_from_slice(EFFORTS); bot.send(chat, tr(de, "Denktiefe für die nächsten Nachrichten:", "Reasoning effort for the next messages:"), Some(choice_keyboard(&values, &cur, "E"))).await?; return Ok(()); } if arg != "auto" && !EFFORTS.contains(&arg) { bot.send(chat, &esc(&format!("{}: {arg}", tr(de, "Unbekannte Stufe", "Unknown level"))), None).await?; return Ok(()); } update(app, &u.id, |s| s.effort = (arg != "auto").then(|| arg.to_string())); bot.send(chat, &format!("{}: {}", tr(de, "Denktiefe", "Effort"), esc(arg)), None).await?; Ok(()) } async fn costs(app: &App, u: &User, bot: &Bot, chat: i64, de: bool) -> Result<()> { let cfg = app.config(); let now = chrono::Local::now(); let offset = now.offset().local_minus_utc() as i64 / 60; let day = now.date_naive().and_hms_opt(0, 0, 0).and_then(|d| d.and_local_timezone(chrono::Local).single()).map(|d| d.timestamp_millis()).unwrap_or(0); let total = |from: i64| app.store.global_stats(from, now.timestamp_millis() + 1, offset, Some(&u.id)).ok().map(|v| (v["total"]["cost"].as_f64().unwrap_or(0.0), v["total"]["requests"].as_i64().unwrap_or(0))).unwrap_or((0.0, 0)); let (today, req_today) = total(day); let (month, req_month) = total(crate::budget::month_start()); let cur = &cfg.server.currency; let mut text = format!( "{} {}: {today:.2} {cur} ({req_today}) {}: {month:.2} {cur} ({req_month})", tr(de, "Kosten", "Costs"), tr(de, "Heute", "Today"), tr(de, "Monat", "Month"), ); if u.is_admin() { let meters = crate::budget::meters(app).unwrap_or_default(); if !meters.is_empty() { text.push_str(&format!("\n\n{}", tr(de, "Budgets", "Budgets"))); for m in meters { let name = match m.kind.as_str() { "total" => tr(de, "Gesamt", "Total").to_string(), "provider" => m.provider.clone(), "images" => tr(de, "Bilder", "Images").to_string(), w => format!("{} · {w}", m.provider), }; text.push_str(&format!("\n{}: {:.0} % ({:.2} / {:.2})", esc(&name), m.ratio * 100.0, m.used, m.limit)); } } } bot.send(chat, &text, None).await?; Ok(()) } async fn memory(app: &App, u: &User, bot: &Bot, chat: i64, de: bool) -> Result<()> { let personal: String = app.store.user_setting(&u.id, "memory").unwrap_or_default(); let project = current(app, u, de).ok().and_then(|s| std::fs::read_to_string(PathBuf::from(&s.cwd).join(".codecraft/memory.md")).ok()).unwrap_or_default(); let cut = |t: &str| -> String { if t.trim().is_empty() { tr(de, "(leer)", "(empty)").to_string() } else { t.chars().take(1500).collect() } }; let text = format!("{}\n{}\n\n{}\n{}", tr(de, "Persönlich", "Personal"), md_to_html(&cut(&personal)), tr(de, "Projekt", "Project"), md_to_html(&cut(&project))); bot.send(chat, &text, None).await?; Ok(()) } async fn jobs(app: &App, u: &User, bot: &Bot, chat: i64, de: bool) -> Result<()> { let list = app.store.jobs(Some(&u.id))?; if list.is_empty() { bot.send(chat, tr(de, "Keine Automationen.", "No automations."), None).await?; return Ok(()); } let keyboard: Vec = list .iter() .take(20) .map(|j| { let d = crate::jobs::describe(j); let name = d["spec"]["name"].as_str().unwrap_or(&j.path).to_string(); let state = if j.enabled { d["spec"]["schedule"].as_str().unwrap_or("").to_string() } else { tr(de, "aus", "off").to_string() }; json!([{"text": format!("▶ {} ({state})", name.chars().take(40).collect::()), "callback_data": format!("j:{}", j.id)}]) }) .collect(); bot.send(chat, tr(de, "Automationen – antippen startet jetzt:", "Automations – tap to run now:"), Some(json!(keyboard))).await?; Ok(()) } async fn callback(app: &Arc, user_id: &str, bot: &Bot, chat: i64, data: &str, message: Option) -> Result<()> { let u = user(app, user_id)?; let de = german(app, user_id); let (kind, rest) = data.split_once(':').unwrap_or((data, "")); match kind { "s" => { let Some(s) = own_sessions(app, user_id).into_iter().find(|s| s.id == rest) else { return Ok(()) }; update(app, user_id, |st| st.session = Some(s.id.clone())); announce(app, bot, chat, &s, de).await } "m" => set_mode(app, &u, bot, chat, rest, de).await, "P" => set_permissions(app, &u, bot, chat, rest, de).await, "E" => set_effort(app, &u, bot, chat, rest, de).await, "j" => { let Some(job) = app.store.job(rest)?.filter(|j| j.owner_id == u.id) else { return Ok(()) }; let sid = crate::jobs::run(app, &job, "telegram").await?; update(app, user_id, |st| st.session = Some(sid.clone())); bot.send(chat, tr(de, "Automation gestartet – die Sitzung ist jetzt aktuell.", "Automation started – its session is now current."), None).await?; Ok(()) } "M" => { let Some(m) = rest.parse::().ok().and_then(|i| models(app).get(i).cloned()) else { return Ok(()) }; set_model(app, &u, bot, chat, &m, de).await } "p" => { // p:: let (id, what) = rest.rsplit_once(':').unwrap_or((rest, "r")); let reply = match what { "o" => PermissionReply::Once, "a" => PermissionReply::Always, _ => PermissionReply::Reject, }; let done = app.reply_permission(id, reply, None, None).is_ok(); if let Some(mid) = message { let text = match (done, what) { (false, _) => tr(de, "(bereits erledigt)", "(already handled)"), (_, "o") => tr(de, "Erlaubt (einmal)", "Allowed (once)"), (_, "a") => tr(de, "Erlaubt (für die Sitzung)", "Allowed (for the session)"), _ => tr(de, "Abgelehnt", "Rejected"), }; let _ = bot.edit(chat, mid, &format!("{}\n\n{text}", esc(&PENDING_TEXT.lock().unwrap().remove(id).unwrap_or_default())), None).await; } Ok(()) } "q" => { // q:::\n"); } else { let lang = t.trim_start_matches('`').trim(); if lang.is_empty() { out.push_str("
");
                } else {
                    out.push_str(&format!("
", esc(lang)));
                }
            }
            in_code = !in_code;
            continue;
        }
        if in_code {
            out.push_str(&esc(line));
            out.push('\n');
            continue;
        }
        let heading = t.strip_prefix("### ").or(t.strip_prefix("## ")).or(t.strip_prefix("# "));
        let line_html = inline(heading.unwrap_or(line));
        if heading.is_some() {
            out.push_str(&format!("{line_html}\n"));
        } else {
            let bullet = t.strip_prefix("- ").or(t.strip_prefix("* "));
            match bullet {
                Some(rest) => out.push_str(&format!("• {}\n", inline(rest))),
                None => {
                    out.push_str(&line_html);
                    out.push('\n');
                }
            }
        }
    }
    if in_code {
        out.push_str("
"); } out.trim_end().to_string() } fn inline(s: &str) -> String { // Split off inline code first: nothing inside is formatted. let mut out = String::new(); for (i, seg) in s.split('`').enumerate() { if i % 2 == 1 { out.push_str(&format!("{}", esc(seg))); continue; } let mut x = esc(seg); let link = regex::Regex::new(r"\[([^\]]+)\]\((https?://[^)\s]+)\)").unwrap(); x = link.replace_all(&x, r#"$1"#).into_owned(); let bold = regex::Regex::new(r"\*\*([^*]+)\*\*").unwrap(); x = bold.replace_all(&x, "$1").into_owned(); let italic = regex::Regex::new(r"(^|[^*\w])\*([^*\s][^*]*)\*").unwrap(); x = italic.replace_all(&x, "$1$2").into_owned(); out.push_str(&x); } // An unpaired backtick leaves an odd segment count: that last segment was code. out } /// A fresh six-digit pairing code. pub fn new_pair_code() -> String { use rand::RngExt; format!("{:06}", rand::rng().random_range(0..1_000_000)) } #[cfg(test)] mod tests { use super::*; #[test] fn markdown_to_telegram_html() { let md = "# Title\nSome **bold** and *it* with `a` and [link](https://x.org).\n- item\n```rust\nfn main() { 1 < 2 }\n```"; let html = md_to_html(md); assert!(html.contains("Title")); assert!(html.contains("bold")); assert!(html.contains("it")); assert!(html.contains("a<b>")); assert!(html.contains(r#"link"#)); assert!(html.contains("• item")); assert!(html.contains("
fn main() { 1 < 2 }\n
")); } #[test] fn splits_long_answers_and_keeps_code_fences_closed() { let code: String = (0..400).map(|i| format!("line {i} of a long listing\n")).collect(); let md = format!("intro\n```\n{code}```\nend"); let chunks = split_markdown(&md); assert!(chunks.len() > 1); for c in &chunks { assert!(c.len() <= CHUNK + 10, "chunk too long: {}", c.len()); assert_eq!(c.matches("```").count() % 2, 0, "unbalanced fence in chunk"); } } #[test] fn finds_the_token_in_pasted_text() { let msg = "Done! Congratulations on your new bot.\nUse this token to access the HTTP API:\n7123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw4\nKeep your token secure"; assert_eq!(extract_token(msg).as_deref(), Some("7123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw4")); assert_eq!(extract_token(" 7123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw4 ").as_deref(), Some("7123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw4")); assert_eq!(extract_token("my-password-123"), None); } #[test] fn step_lines() { assert_eq!(step_line("bash", &json!({"command": "cargo test\nmore"})), "bash: cargo test"); assert_eq!(step_line("read", &json!({"path": "src/main.rs"})), "read: src/main.rs"); assert_eq!(step_line("todo_write", &json!({})), "todo_write"); } }