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
+158 -1
View File
@@ -1,12 +1,17 @@
use std::sync::Arc;
use serde_json::json;
use anyhow::Result;
use serde_json::{json, Value};
use teloxide::prelude::*;
use teloxide::types::InputFile;
use core_api::interface_tool::InterfaceTool;
use core_api::tool::{Tool, ToolCategory, ToolDescriptionLength};
use core_api::tts::{TextToSpeech, TtsProvider};
use super::auth::{Binding, load_config, save_config};
use super::TelegramPlugin;
/// Returns all LLM-callable tools available in a Telegram session.
///
/// Each tool captures `bot` and `chat_id` so its handler can send content
@@ -225,3 +230,155 @@ async fn to_ogg_opus(audio: Vec<u8>, format: &str) -> anyhow::Result<Vec<u8>> {
}
Ok(out.stdout)
}
// ── telegram_pairing (registry tool, category Config) ─────────────────────────
/// Tool that binds a Telegram `chat_id` to a Skald `user_id`.
///
/// Category `Config` — excluded from the default tool list, activated
/// explicitly by the admin's agent. The admin calls this after a user reports
/// their pairing code from Telegram.
///
/// The binding is written to the config table (key `"telegram"`); the
/// resulting `ConfigKeyUpdated` event reloads the plugin's in-memory cache
/// instantly.
pub struct TelegramPairingTool {
plugin: Arc<TelegramPlugin>,
}
impl TelegramPairingTool {
pub fn new(plugin: Arc<TelegramPlugin>) -> Self {
Self { plugin }
}
}
impl Tool for TelegramPairingTool {
fn name(&self) -> &str { "telegram_pairing" }
fn category(&self) -> ToolCategory { ToolCategory::Config }
fn description(&self) -> &str {
"Bind a Telegram chat to a Skald user so they can chat with the agent via Telegram. \
Use `action: \"bind\"` with either a `code` (from the pairing message the user received) \
or a `chat_id` + `user_id`. Use `action: \"list\"` to see current bindings. \
Use `action: \"unbind\"` with a `chat_id` to remove a binding."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["bind", "unbind", "list"],
"description": "bind: create a chat_id→user_id binding. unbind: remove it. list: show all bindings.",
"default": "bind"
},
"code": {
"type": "string",
"description": "Pairing code shown to the Telegram user (alternative to chat_id+user_id)."
},
"chat_id": {
"type": "integer",
"description": "Telegram chat id (use when not resolving via code)."
},
"user_id": {
"type": "string",
"description": "Skald user id to bind to (required for bind when not using code)."
}
}
})
}
fn describe(&self, _args: &Value, _length: ToolDescriptionLength) -> String {
"telegram_pairing".to_string()
}
fn execute(&self, args: Value) -> Result<String> {
let shared = self.plugin.shared()
.ok_or_else(|| anyhow::anyhow!("telegram: plugin is not running"))?
.clone();
let action = args.get("action")
.and_then(Value::as_str)
.unwrap_or("bind");
// Block on since Tool::execute is sync.
let rt = tokio::runtime::Handle::try_current()
.map_err(|e| anyhow::anyhow!("telegram_pairing: no tokio runtime: {e}"))?;
rt.block_on(async {
let cfg_api = &*shared.config;
match action {
"list" => {
let cfg = load_config(cfg_api).await.unwrap_or_default();
if cfg.bindings.is_empty() {
return Ok("No Telegram bindings.".to_string());
}
let lines: Vec<String> = cfg.bindings.iter()
.map(|b| format!(" chat_id={} → user_id={}{}", b.chat_id, b.user_id,
b.display.as_ref().map(|d| format!(" ({d})")).unwrap_or_default()))
.collect();
Ok(format!("Telegram bindings:\n{}", lines.join("\n")))
}
"unbind" => {
let chat_id = args.get("chat_id")
.and_then(Value::as_i64)
.ok_or_else(|| anyhow::anyhow!("telegram_pairing: `chat_id` required for unbind"))?;
let mut cfg = load_config(cfg_api).await.unwrap_or_default();
let before = cfg.bindings.len();
cfg.bindings.retain(|b| b.chat_id != chat_id);
if cfg.bindings.len() == before {
return Ok(format!("chat_id {chat_id} is not bound."));
}
save_config(cfg_api, &cfg).await?;
Ok(format!("Unbound chat_id {chat_id}."))
}
"bind" => {
let mut cfg = load_config(cfg_api).await.unwrap_or_default();
// Resolve chat_id + user_id either from a pairing code or
// from explicit arguments.
let (chat_id, user_id) = if let Some(code) = args.get("code").and_then(Value::as_str) {
let entry = cfg.pending_pairings.iter()
.find(|e| e.code == code)
.ok_or_else(|| anyhow::anyhow!("telegram_pairing: code '{code}' not found (it may have expired or already been used)"))?;
let chat_id = entry.chat_id;
let user_id = args.get("user_id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("telegram_pairing: `user_id` required (the code only identifies the chat)"))?
.to_string();
// Remove the used pairing entry.
cfg.pending_pairings.retain(|e| e.code != code);
(chat_id, user_id)
} else {
let chat_id = args.get("chat_id")
.and_then(Value::as_i64)
.ok_or_else(|| anyhow::anyhow!("telegram_pairing: either `code` or `chat_id`+`user_id` required"))?;
let user_id = args.get("user_id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("telegram_pairing: `user_id` required"))?
.to_string();
(chat_id, user_id)
};
// Replace any existing binding for this chat_id.
cfg.bindings.retain(|b| b.chat_id != chat_id);
cfg.bindings.push(Binding {
chat_id,
user_id: user_id.clone(),
display: None,
});
save_config(cfg_api, &cfg).await?;
Ok(format!("Bound Telegram chat_id {chat_id} to user_id {user_id}."))
}
other => Err(anyhow::anyhow!("telegram_pairing: unknown action '{other}'")),
}
})
}
}