Telegram bot as a chat interface (per user, paired, CLI-like commands)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 16:50:51 +01:00
1 parent b7c8741a7d
commit 011756ca3d
12 files changed
+1361 -17

No files matched your search

+61 -16
View File
@@ -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<User>, State(app): State<AppState>, 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<User>, State(app): State<AppState>) -> 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<User>, State(app): State<AppState>, Json(b): Json<TelegramBody>) -> 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<User>, State(app): State<AppState>) -> 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<User>, State(app): State<AppState>) -> 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<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> 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<ImageData>, effort: Option<&str>) -> Result<Value, ApiError> {
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<User>, State(app): State<AppStat
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?})))
}
async fn do_compact(app: AppState, id: String) {
pub(crate) async fn do_compact(app: AppState, id: String) {
let cancel = tokio_util::sync::CancellationToken::new();
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
+2
View File
@@ -30,6 +30,7 @@ mod runner;
mod sandbox;
mod shells;
mod store;
mod telegram;
mod terminal;
mod tools;
mod types;
@@ -73,6 +74,7 @@ async fn main() -> 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?;
File diff suppressed because it is too large. Load diff