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:
1 parent
b7c8741a7d
commit
011756ca3d
12 files changed
+1361
-17
No files matched your search
+61
-16
@@ -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 });
|
||||
|
||||
@@ -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
Reference in new issue
Block a user