telegram bot user isolation, config store, user context channels

This commit is contained in:
2026-07-11 01:02:37 +01:00
parent 587958ffe0
commit 5848829a92
18 changed files with 1179 additions and 426 deletions
+89 -103
View File
@@ -8,11 +8,13 @@ use core_api::chat_hub::{ModelCommandOutcome, SendMessageOptions};
use core_api::command::expand_template;
use core_api::location::GpsCoord;
use core_api::message_meta::{CommandRef, MessageMetadata};
use core_api::user_channel::UserChannelHandle;
use super::TELEGRAM_FORMAT_CONTEXT;
use super::TgShared;
use super::attachments::TelegramAttachment;
use super::auth::{handle_pairing, load_wl};
use super::auth::handle_pairing;
use super::events::ensure_forwarder;
// ── Available commands help text (shared by /help and unknown-command replies) ──
const HELP_TEXT: &str = "<b>Available commands</b>\n\n\
@@ -28,9 +30,6 @@ const HELP_TEXT: &str = "<b>Available commands</b>\n\n\
/sethome — receive agent notifications here\n\
/help — this message";
/// Builds the `/help` text: the static system-command list plus a dynamically
/// discovered "Custom commands" section (`commands/<name>/`). Descriptions are
/// HTML-escaped since the message is sent with `ParseMode::Html`.
fn help_text(command: &dyn core_api::command::CommandApi) -> String {
let mut out = String::from(HELP_TEXT);
let cmds = command.list_enabled();
@@ -48,9 +47,6 @@ fn help_text(command: &dyn core_api::command::CommandApi) -> String {
}
// ── Incoming message classification ───────────────────────────────────────────
//
// To add a new media type: add a variant to IncomingEvent, handle it in
// classify_message, then dispatch it in message_handler.
pub(crate) enum IncomingEvent {
Text(String),
@@ -93,13 +89,6 @@ pub(crate) fn classify_message(msg: &Message) -> Option<IncomingEvent> {
let text = msg.text()?;
// A command is any message that *starts* with '/'. We deliberately do NOT
// rely on teloxide's BotCommand entities: those are emitted for every
// "/token" anywhere in the text, so a normal sentence containing a "/path"
// (e.g. "stop /usr/bin/foo") would be misclassified as a command. A leading
// slash is the only signal. Arguments are parsed from the message `text`
// (not `entity.text()`, which spans only "/model" and would drop the arg).
// An unknown command is handled by the dispatcher, which replies with help.
if text.starts_with('/') {
let full = text.trim_start_matches('/');
let mut parts = full.splitn(2, ' ');
@@ -126,31 +115,58 @@ pub(crate) async fn message_handler(
) -> ResponseResult<()> {
let chat_id = msg.chat.id;
// Whitelist check — re-read the file on every message so agent edits are
// picked up without a plugin restart.
let wl = load_wl(&shared.secrets_dir).await;
if !wl.whitelist.contains(&chat_id.0) {
handle_pairing(&bot, chat_id, &shared).await;
return Ok(());
}
// Resolve chat_id → user_id from bindings.
let user_id = match shared.user_for_chat(chat_id.0).await {
Some(uid) => uid,
None => {
handle_pairing(&bot, chat_id, &shared).await;
return Ok(());
}
};
// Track the last active chat_id so the persistent forwarder knows
// where to send background notifications.
*shared.home_chat_id.lock().await = Some(chat_id);
// Resolve the user's per-user context (must be unlocked, §9).
let handle = match shared.user_channel.resolve_user(&user_id).await {
Some(h) => h,
None => {
bot.send_message(
chat_id,
"🔒 Your account is locked. Please log in via the web app first, \
then send another message.",
)
.await
.ok();
return Ok(());
}
};
// Ensure a per-user forwarder is running so the response events reach
// this Telegram chat. The forwarder will exit on broadcast-close (user
// locked) or plugin stop; a fresh CancellationToken is fine here since
// the global plugin cancel drops the dispatcher (and thus the shared Arc).
ensure_forwarder(
bot.clone(),
Arc::clone(&shared),
&user_id,
chat_id.0,
Arc::clone(&handle),
tokio_util::sync::CancellationToken::new(),
).await;
let Some(incoming) = classify_message(&msg) else {
bot.send_message(chat_id, "Unsupported message format.").await.ok();
return Ok(());
};
let hub = handle.chat_hub();
match incoming {
IncomingEvent::Command { ref name, .. } if name == "clear" || name == "new" => {
handle_clear(&bot, chat_id, &shared).await;
handle_clear(&bot, chat_id, &hub).await;
}
IncomingEvent::Command { ref name, .. } if name == "sethome" => {
match shared.chat_hub.set_home("telegram").await {
match hub.set_home("telegram").await {
Ok(_) => {
info!("telegram: set as home source");
info!("telegram: set as home source for user {}", user_id);
bot.send_message(chat_id, "🏠 Telegram set as <b>home</b>. Agent notifications will be delivered here.")
.parse_mode(ParseMode::Html)
.await
@@ -168,30 +184,28 @@ pub(crate) async fn message_handler(
.ok();
}
IncomingEvent::Command { ref name, .. } if name == "stop" => {
handle_stop(&bot, chat_id, &shared).await;
hub.cancel("telegram").await;
info!("telegram: agent cancelled via /stop");
bot.send_message(chat_id, "⏹ Agent stopped.").await.ok();
}
IncomingEvent::Command { ref name, .. } if name == "context" => {
handle_context(&bot, chat_id, &shared).await;
handle_context(&bot, chat_id, &hub).await;
}
IncomingEvent::Command { ref name, .. } if name == "cost" => {
handle_cost(&bot, chat_id, &shared).await;
handle_cost(&bot, chat_id, &hub).await;
}
IncomingEvent::Command { ref name, .. } if name == "compact" => {
handle_compact(&bot, chat_id, &shared).await;
handle_compact(&bot, chat_id, &hub).await;
}
IncomingEvent::Command { ref name, .. } if name == "resettools" => {
handle_reset_mcp(&bot, chat_id, &shared).await;
handle_reset_mcp(&bot, chat_id, &hub).await;
}
IncomingEvent::Command { ref name, .. } if name == "models" => {
handle_list_models(&bot, chat_id, &shared).await;
handle_list_models(&bot, chat_id, &hub).await;
}
IncomingEvent::Command { ref name, ref args, .. } if name == "model" => {
handle_set_model(&bot, chat_id, args, &shared).await;
handle_set_model(&bot, chat_id, args, &hub).await;
}
// A recognised custom `/command` expands its `COMMAND.md` template into a
// normal user message (fully interactive: the model can then ask questions,
// iterate, dispatch sub-agents). Any other `/...` is an unknown command and
// is never forwarded to the LLM — reply with a not-found notice + help.
IncomingEvent::Command { ref name, ref args, .. } => {
if let Some(resolved) = shared.command.resolve(name) {
let args_str = args.join(" ");
@@ -208,7 +222,7 @@ pub(crate) async fn message_handler(
}),
..Default::default()
};
handle_llm_message(bot, chat_id, content, Some(metadata), shared).await;
handle_llm_message(bot, chat_id, content, Some(metadata), shared, &handle).await;
} else {
bot.send_message(
chat_id,
@@ -220,10 +234,10 @@ pub(crate) async fn message_handler(
}
}
IncomingEvent::Voice { file_id } => {
handle_voice(&bot, chat_id, file_id, &shared).await;
handle_voice(&bot, chat_id, file_id, &shared, &handle).await;
}
IncomingEvent::Attachment(attachment) => {
handle_attachment(bot, chat_id, attachment, shared).await;
handle_attachment(bot, chat_id, attachment, shared, &handle).await;
}
_ => {
let text = match &incoming {
@@ -233,14 +247,15 @@ pub(crate) async fn message_handler(
| IncomingEvent::Attachment(_) => unreachable!(),
};
// If a clarification question is pending, treat any text as the answer.
// If a clarification question is pending for this chat, treat any
// text as the answer.
{
let mut pq = shared.pending_question.lock().await;
if let Some(pq_inner) = pq.take() {
let request_id = pq_inner.request_id;
let question_msg_id = pq_inner.message_id;
drop(pq);
shared.chat_hub.resolve_question("telegram", request_id, text.clone()).await;
let mut pq_map = shared.pending_questions.lock().await;
if let Some(pq) = pq_map.remove(&chat_id.0) {
let request_id = pq.request_id;
let question_msg_id = pq.message_id;
drop(pq_map);
hub.resolve_question("telegram", request_id, text.clone()).await;
tracing::info!(request_id, %text, "telegram: clarification answered via text");
bot.edit_message_reply_markup(chat_id, question_msg_id)
.reply_markup(teloxide::types::InlineKeyboardMarkup::new(vec![vec![
@@ -255,17 +270,17 @@ pub(crate) async fn message_handler(
}
}
handle_llm_message(bot, chat_id, text, None, shared).await;
handle_llm_message(bot, chat_id, text, None, shared, &handle).await;
}
}
Ok(())
}
// ── /clear command ────────────────────────────────────────────────────────────
// ── Command handlers ──────────────────────────────────────────────────────────
async fn handle_clear(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
match shared.chat_hub.clear("telegram").await {
async fn handle_clear(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
match hub.clear("telegram").await {
Ok(_) => {
info!("telegram: session cleared via /clear");
bot.send_message(chat_id, "🆕 New conversation started.").await.ok();
@@ -277,10 +292,8 @@ async fn handle_clear(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
}
}
// ── /context command ──────────────────────────────────────────────────────────
async fn handle_context(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
match shared.chat_hub.context_info("telegram").await {
async fn handle_context(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
match hub.context_info("telegram").await {
Ok((input, output)) => {
let input_str = input.map_or("?".to_string(), |t| t.to_string());
let output_str = output.map_or("?".to_string(), |t| t.to_string());
@@ -298,10 +311,8 @@ async fn handle_context(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
}
}
// ── /cost command ─────────────────────────────────────────────────────────────
async fn handle_cost(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
match shared.chat_hub.cost_info("telegram").await {
async fn handle_cost(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
match hub.cost_info("telegram").await {
Ok(Some(c)) => {
bot.send_message(chat_id, format!("💰 Session cost: ${c:.4}")).await.ok();
}
@@ -314,10 +325,8 @@ async fn handle_cost(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
}
}
// ── /compact command ──────────────────────────────────────────────────────────
async fn handle_compact(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
match shared.chat_hub.force_compact("telegram").await {
async fn handle_compact(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
match hub.force_compact("telegram").await {
Ok(true) => {
info!("telegram: manual compaction succeeded");
bot.send_message(chat_id, "✅ Context compacted.").await.ok();
@@ -332,10 +341,8 @@ async fn handle_compact(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
}
}
// ── /resettools command ───────────────────────────────────────────────────────
async fn handle_reset_mcp(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
match shared.chat_hub.reset_mcp("telegram").await {
async fn handle_reset_mcp(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
match hub.reset_mcp("telegram").await {
Ok(()) => {
info!("telegram: tool-group grants reset via /resettools");
bot.send_message(chat_id, "✅ Activated tool groups removed from the session.").await.ok();
@@ -347,23 +354,8 @@ async fn handle_reset_mcp(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
}
}
// ── /stop command ────────────────────────────────────────────────────────────
async fn handle_stop(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
shared.chat_hub.cancel("telegram").await;
info!("telegram: agent cancelled via /stop");
bot.send_message(chat_id, "⏹ Agent stopped.").await.ok();
}
// ── /models and /model commands ──────────────────────────────────────────────
//
// Business logic (resolve arg, mutate pin, broadcast) lives in
// `ChatHub::apply_model_command` / `ChatHub::list_clients_marked`. Here we only
// format for Telegram (HTML) and send via the bot — same pattern the web WS
// handler uses with Markdown.
async fn handle_list_models(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
let items = shared.chat_hub.list_clients_marked("telegram").await;
async fn handle_list_models(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
let items = hub.list_clients_marked("telegram").await;
let mut text = String::from("<b>Available models</b>\n\n");
for (i, name, is_current) in &items {
let marker = if *is_current { "" } else { "" };
@@ -381,9 +373,9 @@ async fn handle_list_models(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>)
.ok();
}
async fn handle_set_model(bot: &Bot, chat_id: ChatId, args: &[String], shared: &Arc<TgShared>) {
async fn handle_set_model(bot: &Bot, chat_id: ChatId, args: &[String], hub: &Arc<dyn core_api::chat_hub::ChatHubApi>) {
let arg = args.first().cloned().unwrap_or_default();
let outcome = shared.chat_hub.apply_model_command("telegram", &arg).await;
let outcome = hub.apply_model_command("telegram", &arg).await;
let text = match outcome {
ModelCommandOutcome::Set(name) => format!("✅ Model set: <b>{}</b>", super::helpers::escape_html(&name)),
ModelCommandOutcome::Cleared => "✅ Model reset to <b>auto</b>.".to_string(),
@@ -403,25 +395,22 @@ async fn handle_llm_message(
text: String,
metadata: Option<MessageMetadata>,
shared: Arc<TgShared>,
handle: &Arc<dyn UserChannelHandle>,
) {
bot.send_chat_action(chat_id, ChatAction::Typing).await.ok();
// The persistent_forwarder (spawned once in start()) is always subscribed
// to the "telegram" broadcast channel and will pick up all events for this
// turn — including Done → send to Telegram. No per-message subscription needed.
let client_name = shared.chat_hub.get_selected_client("telegram").await;
let hub = handle.chat_hub();
let client_name = hub.get_selected_client("telegram").await;
let opts = SendMessageOptions {
client_name,
extra_system_context: Some(TELEGRAM_FORMAT_CONTEXT.to_string()),
tail_reminder: Some(super::TELEGRAM_FORMAT_REMINDER.to_string()),
interface_tools: super::tools::interface_tools(bot, chat_id, &*shared.tts).await,
interface_tools: super::tools::interface_tools(bot.clone(), chat_id, &*shared.tts).await,
metadata,
..Default::default()
};
// send_message only enqueues — the turn runs on ChatHub's per-source consumer —
// so awaiting inline keeps this message handler responsive.
if let Err(e) = shared.chat_hub.send_message("telegram", &text, opts).await {
if let Err(e) = hub.send_message("telegram", &text, opts).await {
error!(error = %e, "telegram: enqueue error");
}
}
@@ -433,6 +422,7 @@ async fn handle_voice(
chat_id: ChatId,
file_id: String,
shared: &Arc<TgShared>,
handle: &Arc<dyn UserChannelHandle>,
) {
use teloxide::net::Download;
@@ -477,7 +467,7 @@ async fn handle_voice(
The user sent a voice message. The following is the audio transcript:\n\n\
{text}"
);
handle_llm_message(bot.clone(), chat_id, message, None, Arc::clone(shared)).await;
handle_llm_message(bot.clone(), chat_id, message, None, Arc::clone(shared), handle).await;
}
// ── Edited message (live location updates) ────────────────────────────────────
@@ -500,8 +490,8 @@ async fn handle_attachment(
chat_id: ChatId,
attachment: TelegramAttachment,
shared: Arc<TgShared>,
handle: &Arc<dyn UserChannelHandle>,
) {
// Update LocationManager immediately, before any LLM dispatch.
if let TelegramAttachment::Location { latitude, longitude, accuracy, is_live } = &attachment {
let coord = GpsCoord { latitude: *latitude, longitude: *longitude };
shared.location.update("telegram", coord, *accuracy, *is_live);
@@ -519,9 +509,6 @@ async fn handle_attachment(
};
match saved {
// Document / Photo: carry the file as structured metadata (rendered as a
// chip in the copilot UI; the LLM gets the shared [SYSTEM INFO] block).
// The caption, if any, becomes the user's text for this turn.
Some(att) => {
info!(chat_id = chat_id.0, path = %att.path, "telegram: attachment saved, forwarding to LLM");
let caption = match &attachment {
@@ -530,12 +517,11 @@ async fn handle_attachment(
TelegramAttachment::Location { .. } => None,
}.unwrap_or_default();
let metadata = MessageMetadata { attachments: vec![att], ..Default::default() };
handle_llm_message(bot, chat_id, caption, Some(metadata), shared).await;
handle_llm_message(bot, chat_id, caption, Some(metadata), shared, handle).await;
}
// Location (no file): keep the textual system-info block.
None => {
let message = attachment.system_info_message(None);
handle_llm_message(bot, chat_id, message, None, shared).await;
handle_llm_message(bot, chat_id, message, None, shared, handle).await;
}
}
}