From 011756ca3d5e0612bcabea94855e82bae83ae56c Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Mon, 9 Nov 2026 16:50:51 +0100 Subject: [PATCH] Telegram bot as a chat interface (per user, paired, CLI-like commands) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 + Cargo.lock | 1 + README.md | 3 + server/Cargo.toml | 2 +- server/src/api.rs | 77 +- server/src/main.rs | 2 + server/src/telegram.rs | 1154 ++++++++++++++++++++++ web/src/components/settings/Account.tsx | 2 + web/src/components/settings/Telegram.tsx | 94 ++ web/src/i18n/de.ts | 13 + web/src/i18n/en.ts | 13 + web/src/whatsnew.ts | 4 + 12 files changed, 1361 insertions(+), 17 deletions(-) create mode 100644 server/src/telegram.rs create mode 100644 web/src/components/settings/Telegram.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 7def59f..ee61677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Telegram bot as a chat interface:** every user can connect their own bot (*Settings → + Account → Telegram*: token from @BotFather, then `/start ` pairs it with their Telegram + account – other chats are ignored). Messages, photos and files go to the current session; the + agent's steps show live, the answer comes formatted, images from tools come as photos, and + approvals and questions have buttons. Commands like the terminal client: `/sessions`, + `/session`, `/new`, `/clear`, `/mode`, `/model`, `/permissions`, `/effort`, `/stop`, `/status`, + `/costs`, `/memory`, `/jobs`, `/compact`, `/help`. The server polls Telegram – no public + webhook needed. + ## [v202611-00006] – 2026-11-09 ### Added diff --git a/Cargo.lock b/Cargo.lock index 4ff411b..41448a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3040,6 +3040,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", diff --git a/README.md b/README.md index dac85ed..0553f18 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,9 @@ shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed t ## Integrations +- **Telegram:** connect your own bot (*Settings → Account → Telegram*) and use CodeCraft from + Telegram – sessions, modes, models, approvals with buttons, photos and files; `/help` in the + bot lists the commands. - **API tokens** (*Settings → Account*) with full or read-only scope for scripts and the terminal client. - **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with `Authorization: Bearer ` – requests, errors, tokens, cost and time per model, provider diff --git a/server/Cargo.toml b/server/Cargo.toml index d63eed2..6400910 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -28,7 +28,7 @@ shell-words = "1" libc = "0.2" ignore = "0.4" regex = "1" -reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form", "multipart"] } rusqlite = { version = "0.41", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/server/src/api.rs b/server/src/api.rs index 4583a79..f37e737 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -138,6 +138,7 @@ pub fn router(app: AppState) -> Router { .route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs)) .route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory)) .route("/account/modes", get(get_personal_modes).put(put_personal_modes)) + .route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram)) .route("/templates", get(crate::cli_api::get_templates)) .route("/templates/{scope}", put(crate::cli_api::put_templates)) .route("/cli/config", get(crate::cli_api::config)) @@ -327,6 +328,43 @@ async fn agents(Extension(user): Extension, State(app): State, Q Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id)))) } +/// Telegram bot of the user: bot name, pairing state, errors (the token stays hidden). +async fn get_telegram(Extension(user): Extension, State(app): State) -> ApiResult { + let s = crate::telegram::load(&app, &user.id); + let (running, error) = crate::telegram::status(&user.id); + Ok(Json(json!({ + "configured": !s.token.is_empty(), + "bot": s.bot, + "paired": s.chat_id.is_some(), + "pair_code": if s.chat_id.is_none() { s.pair_code } else { String::new() }, + "running": running, + "error": error, + }))) +} + +#[derive(Deserialize)] +struct TelegramBody { + token: String, +} + +/// Connect a bot: check the token, start polling and create a pairing code. +async fn put_telegram(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { + let token = b.token.trim().to_string(); + let bot = crate::telegram::check_token(&token).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("token rejected by Telegram: {e:#}")))?; + let s = crate::telegram::Settings { token, bot, pair_code: crate::telegram::new_pair_code(), ..Default::default() }; + app.store.set_user_setting(&user.id, "telegram", &s)?; + crate::telegram::restart(&app, &user.id); + app.audit(Some(&user), "telegram.connect", &s.bot, ""); + get_telegram(Extension(user), State(app)).await +} + +async fn delete_telegram(Extension(user): Extension, State(app): State) -> ApiResult { + crate::telegram::stop(&user.id); + app.store.set_user_setting(&user.id, "telegram", &crate::telegram::Settings::default())?; + app.audit(Some(&user), "telegram.disconnect", "", ""); + Ok(Json(json!({"ok": true}))) +} + /// The user's personal modes (name -> settings). async fn get_personal_modes(Extension(user): Extension, State(app): State) -> ApiResult { Ok(Json(json!(prompts::personal_modes(&app, &user.id)))) @@ -858,41 +896,48 @@ fn set_turn_effort(app: &App, id: &str, effort: Option<&str>) -> Result<(), ApiE async fn prompt(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { let session = writable(&app, &user, &id)?; - let text = body.text.trim().to_string(); + send_prompt(&app, session, body.text, body.images, body.effort.as_deref()).map(Json) +} + +/// Handle a user message for a session (web UI and Telegram): built-in commands, +/// slash command expansion, then start or queue the turn. +pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text: String, images: Vec, effort: Option<&str>) -> Result { + let id = session.id.clone(); + let text = text.trim().to_string(); // Built-in commands. if text == "/compact" { if app.is_busy(&id) { return Err(ApiError(StatusCode::CONFLICT, "session is busy".into())); } tokio::spawn(do_compact(app.clone(), id.clone())); - return Ok(Json(json!({"ok": true, "command": "compact"}))); + return Ok(json!({"ok": true, "command": "compact"})); } let cwd = PathBuf::from(&session.cwd); let root = app.root_for(session.owner_id.as_deref(), &cwd); let mut prompt_text = text.clone(); if text.starts_with('/') { - if let Some((expanded, agent, clean_room)) = prompts::expand_command(&app, &root, &cwd, &text) { + if let Some((expanded, agent, clean_room)) = prompts::expand_command(app, &root, &cwd, &text) { prompt_text = expanded; if agent.is_some() || clean_room.is_some() { - let mut s = session.clone(); - if let Some(a) = agent { - s.agent = a; - } - if clean_room.is_some() { - s.clean_room = clean_room; - } - app.save_session(s)?; + app.update_session(&id, |s| { + if let Some(a) = agent { + s.agent = a; + } + if clean_room.is_some() { + s.clean_room = clean_room; + } + })?; } } } - set_turn_effort(&app, &id, body.effort.as_deref())?; + set_turn_effort(app, &id, effort)?; let mut parts = vec![Part::Text { text: prompt_text }]; - for image in body.images { + for image in images { parts.push(Part::Image { image }); } match runner::submit(app.clone(), id, parts, "user")? { - runner::Submitted::Started => Ok(Json(json!({"ok": true}))), - runner::Submitted::Queued(item) => Ok(Json(json!({"ok": true, "queued": item}))), + runner::Submitted::Started => Ok(json!({"ok": true})), + runner::Submitted::Queued(item) => Ok(json!({"ok": true, "queued": item})), } } @@ -916,7 +961,7 @@ async fn flush_queue(Extension(user): Extension, State(app): State Result<()> { health::spawn_checker(app.clone()); digest::spawn(app.clone()); budget::spawn_alerts(app.clone()); + telegram::spawn(app.clone()); backup::spawn(app.clone()); let router = api::router(app); let listener = tokio::net::TcpListener::bind(&bind).await?; diff --git a/server/src/telegram.rs b/server/src/telegram.rs new file mode 100644 index 0000000..3ee5625 --- /dev/null +++ b/server/src/telegram.rs @@ -0,0 +1,1154 @@ +//! 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)) + } +} + +/// 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")) +} + +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, + "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 { 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 { 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/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/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, + })?; + 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(()) +} + +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 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"); + } +} diff --git a/web/src/components/settings/Account.tsx b/web/src/components/settings/Account.tsx index b972a35..f4c8326 100644 --- a/web/src/components/settings/Account.tsx +++ b/web/src/components/settings/Account.tsx @@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount"; import { ApiTokens } from "./ApiTokens"; import { CliInstall } from "./CliInstall"; import { Memory } from "./Memory"; +import { Telegram } from "./Telegram"; export function Account() { const t = useT(); @@ -51,6 +52,7 @@ export function Account() { + ); diff --git a/web/src/components/settings/Telegram.tsx b/web/src/components/settings/Telegram.tsx new file mode 100644 index 0000000..cdefb6c --- /dev/null +++ b/web/src/components/settings/Telegram.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { del, put } from "../../api"; +import { confirmDialog, toast } from "../../dialogs"; +import { useFetch } from "../../hooks"; +import { useT } from "../../i18n"; +import { Icon } from "../Icon"; + +interface Status { + configured: boolean; + bot: string; + paired: boolean; + pair_code: string; + running: boolean; + error?: string | null; +} + +/** Connect a personal Telegram bot as a chat interface. */ +export function Telegram() { + const t = useT(); + const { data, reload } = useFetch("/account/telegram"); + const [token, setToken] = useState(""); + const [busy, setBusy] = useState(false); + + const connect = async () => { + setBusy(true); + try { + await put("/account/telegram", { token: token.trim() }); + setToken(""); + reload(); + } catch (e) { + toast(e); + } finally { + setBusy(false); + } + }; + const disconnect = async () => { + if (!(await confirmDialog({ message: t("telegram.disconnectConfirm"), danger: true, confirmLabel: t("telegram.disconnect") }))) return; + await del("/account/telegram").catch((e) => toast(e)); + reload(); + }; + + return ( +
+

+ {t("telegram.title")} + + {data?.configured && ( + + )} +

+
+ {t("telegram.intro")} +
+ {data && !data.configured && ( + <> +
    +
  1. {t("telegram.step1")}
  2. +
  3. {t("telegram.step2")}
  4. +
+
+ setToken(e.target.value)} style={{ flex: 1 }} /> + +
+ + )} + {data?.configured && ( + <> +
+ @{data.bot} + {data.running && !data.error ? t("telegram.running") : t("telegram.stopped")} + {data.paired && {t("telegram.paired")}} +
+ {!data.paired && data.pair_code && ( +
+ {t("telegram.pairHint", { bot: data.bot })}{" "} + + t.me/{data.bot} + +
+ /start {data.pair_code} +
+
+ )} + {data.error &&
{data.error}
} + {data.paired &&
{t("telegram.commands")}
} + + )} +
+ ); +} diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 16c00c3..bb55e2e 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -405,6 +405,19 @@ export const de: Messages = { "tokens.title": "API-Tokens", "tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer “ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).", "tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:", + "telegram.title": "Telegram", + "telegram.intro": "CodeCraft über Telegram mit deinem eigenen Bot nutzen: Nachrichten gehen an deine aktuelle Sitzung, Befehle wechseln Sitzungen, Modi und Modelle, Freigaben und Rückfragen kommen mit Knöpfen. Nur dein Telegram-Konto kann den Bot nutzen.", + "telegram.step1": "In Telegram @BotFather öffnen, /newbot senden und den Schritten folgen.", + "telegram.step2": "Das Token von BotFather hier einfügen.", + "telegram.connect": "Verbinden", + "telegram.checking": "Prüfe…", + "telegram.running": "läuft", + "telegram.stopped": "läuft nicht", + "telegram.paired": "gekoppelt", + "telegram.pairHint": "Letzter Schritt: deinen Bot öffnen und den Code senden (oder den Link antippen) – danach bedient er nur noch dein Telegram-Konto:", + "telegram.commands": "In Telegram: /help zeigt die Befehle – /sessions, /new, /mode, /model, /stop, /status, /costs …", + "telegram.disconnect": "Trennen", + "telegram.disconnectConfirm": "Telegram-Bot trennen? Er antwortet dann nicht mehr; mit seinem Token kannst du ihn wieder verbinden.", "tokens.copy": "Kopieren", "tokens.copied": "Kopiert", "tokens.name": "Name", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index d90becb..c053e6c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -404,6 +404,19 @@ export const en = { "tokens.title": "API tokens", "tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer ”. Read-only tokens can only read (no prompts, no changes).", "tokens.copyNow": "Copy the token now – it is shown only once:", + "telegram.title": "Telegram", + "telegram.intro": "Use CodeCraft from Telegram with your own bot: messages go to your current session, commands switch sessions, modes and models, approvals and questions come with buttons. Only your Telegram account can use the bot.", + "telegram.step1": "In Telegram, open @BotFather, send /newbot and follow the steps.", + "telegram.step2": "Paste the token BotFather gives you here.", + "telegram.connect": "Connect", + "telegram.checking": "Checking…", + "telegram.running": "running", + "telegram.stopped": "not running", + "telegram.paired": "paired", + "telegram.pairHint": "Last step: open your bot and send the code (or tap the link) – from then on only your Telegram account is served:", + "telegram.commands": "In Telegram: /help lists the commands – /sessions, /new, /mode, /model, /stop, /status, /costs …", + "telegram.disconnect": "Disconnect", + "telegram.disconnectConfirm": "Disconnect the Telegram bot? It stops answering; you can connect it again with its token.", "tokens.copy": "Copy", "tokens.copied": "Copied", "tokens.name": "Name", diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index 3cbf288..254f1c4 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,10 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: ["Telegram: connect your own bot under Settings → Account → Telegram and chat with CodeCraft from your phone"], + de: ["Telegram: eigenen Bot unter Einstellungen → Konto → Telegram verbinden und von unterwegs mit CodeCraft chatten"], + }, "202611-00006": { en: ["Docker CLI in the image – with the host's Docker socket mounted, admins' agents and terminals can use Docker"], de: ["Docker-CLI im Image – mit eingehängtem Docker-Socket können Admin-Agents und -Terminals Docker nutzen"],