Files
Skald-Circle/crates/plugin-telegram-bot/src/lib.rs
T
dguiducci 4b1affa600
Nightly Build / build (push) Successful in 7m1s
plugins: merge the user Plugins page into per-plugin sidebar pages
The generic per-user #plugins page is gone: a plugin with per-user
settings hosts them in its own web_pages() sidebar page instead
(Telegram's pairing page is new; Honcho's opt-in page already existed).
The admin catalog moves from #plugin-catalog to #plugins (old hash
redirected), and user_config_schema is removed from the Plugin trait,
the API DTOs and both plugins — the my-config endpoint, the
plugin_user_configs store and the update_user_config hook stay, now
driven by each plugin's own page fragment.
2026-07-28 20:48:03 +01:00

380 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Telegram plugin — connects the Skald LLM to a private Telegram bot.
///
/// # Multi-user architecture (blueprint §13)
///
/// One bot serves many Telegram chats, each bound to a Skald user via the
/// `chat_id ↔ user_id` pairing stored in the config table (key `"telegram"`).
/// Incoming messages resolve the user's per-user context via
/// [`UserChannelApi`], then dispatch through that user's `ChatHub`. A per-user
/// forwarder subscribes to the user's event stream and routes `ServerEvent`s
/// back to the bound Telegram chat.
///
/// # Pairing
///
/// Unknown chats receive a pairing code. The user links their own account by
/// pasting the code in the plugin's own Telegram page in the web app's sidebar
/// (served as a `web_pages()` fragment, saved through the core
/// `PUT /api/plugins/telegram/my-config` endpoint into the
/// `update_user_config` hook); the admin's agent can also bind a chat via the
/// `telegram_pairing` tool (category `Config`). The binding is written to the
/// config table; the resulting `ConfigKeyUpdated` event reloads the in-memory
/// cache instantly.
///
/// # Human-in-the-loop approvals
///
/// Tool calls requiring approval emit a `PendingWrite` / `ApprovalRequired`
/// event; the per-user forwarder sends it to Telegram as an inline-keyboard
/// message. Button presses resolve the approval through that user's
/// `ApprovalApi`.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{Value, json};
use teloxide::prelude::*;
use teloxide::types::MessageId;
use tokio::sync::{Mutex, RwLock};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use core_api::command::CommandApi;
use core_api::config_api::ConfigApi;
use core_api::location::LocationUpdater;
use core_api::plugin::{Plugin, PluginContext, PluginPage};
use core_api::transcribe::TranscribeProvider;
use core_api::tts::TtsProvider;
use core_api::user_channel::UserChannelApi;
mod attachments;
mod auth;
mod events;
mod handlers;
mod helpers;
mod tools;
/// The plugin id — the key into `plugin_access` / `plugin_user_configs` and the
/// value returned by [`Plugin::id`]. Kept in one place so the runtime access
/// check and the registration id can never drift apart.
pub(crate) const PLUGIN_ID: &str = "telegram";
/// Injected as extra system context for every Telegram turn.
/// Kept compact to minimise token overhead.
pub(crate) const TELEGRAM_FORMAT_CONTEXT: &str = "\
OUTPUT FORMAT — TELEGRAM HTML ONLY.\n\
Allowed tags: <b> <i> <u> <s> <code> <pre> <a> <blockquote>. \
Telegram supports NO other HTML and NO Markdown.\n\
FORBIDDEN (will appear as raw symbols): ** * _ ` # | and Markdown tables.\n\
• Headers → <b>text</b>\n\
• Structured data → bullet lists with •, never | tables\n\
• Escape & < > as &amp; &lt; &gt;";
/// Short reminder injected near the end of the message list to counter
/// instruction drift in long conversations.
pub(crate) const TELEGRAM_FORMAT_REMINDER: &str = "\
[FORMAT] Telegram HTML only: <b> <i> <code> <pre>. \
No Markdown: no ** * _ ` # |. No tables — use bullet lists.";
// ── Shared state injected into every teloxide handler ─────────────────────────
/// A pending `ask_user_clarification` question waiting for the user's reply.
pub(crate) struct PendingQuestion {
pub(crate) user_id: String,
pub(crate) request_id: i64,
pub(crate) message_id: MessageId,
/// Suggested answers (used to resolve the selection when the user taps a button).
pub(crate) suggested_answers: Vec<String>,
}
/// A pending tool-call approval shown as an inline keyboard.
pub(crate) struct PendingApproval {
pub(crate) user_id: String,
pub(crate) request_id: i64,
}
/// Global state shared across all Telegram handlers and the per-user forwarders.
///
/// Per-user state (ChatHub, ApprovalApi, event stream) is resolved at runtime
/// via [`UserChannelApi`] — it is NOT held here. Only global capabilities and
/// pairing/multiplexing state live in `TgShared`.
pub(crate) struct TgShared {
// ── Global capabilities ──
pub(crate) user_channel: Arc<dyn UserChannelApi>,
pub(crate) command: Arc<dyn CommandApi>,
pub(crate) config: Arc<dyn ConfigApi>,
pub(crate) transcribe: Arc<dyn TranscribeProvider>,
pub(crate) tts: Arc<dyn TtsProvider>,
pub(crate) location: Arc<dyn LocationUpdater>,
// ── Pairing / bindings (config-table-backed, cached in memory) ──
pub(crate) bindings: RwLock<auth::TelegramConfig>,
// ── Per-chat pending state ──
/// Approval message_id → pending approval (carries user_id for routing).
pub(crate) pending_approvals: Mutex<HashMap<MessageId, PendingApproval>>,
/// chat_id → pending clarification question (at most one per chat).
pub(crate) pending_questions: Mutex<HashMap<i64, PendingQuestion>>,
// ── Forwarder tracking ──
/// user_ids with an active per-user forwarder task.
pub(crate) forwarders: Mutex<HashSet<String>>,
}
impl TgShared {
pub(crate) async fn transcriber(&self) -> Option<Arc<dyn core_api::transcribe::Transcribe>> {
self.transcribe.get().await
}
/// Looks up the `user_id` bound to a Telegram `chat_id`, if any.
pub(crate) async fn user_for_chat(&self, chat_id: i64) -> Option<String> {
self.bindings.read().await
.bindings.iter()
.find(|b| b.chat_id == chat_id)
.map(|b| b.user_id.clone())
}
/// Whether a bound `user_id` may still use this plugin. A binding only says
/// "this chat belongs to this user"; access is a separate, admin-revocable
/// grant (`plugin_access`). Enforced on every inbound message so a revoke
/// takes effect immediately — the binding is left intact so a re-grant
/// restores service without forcing the user to pair again.
pub(crate) async fn user_authorized(&self, user_id: &str) -> bool {
self.user_channel.plugin_access(PLUGIN_ID, user_id).await
}
}
// ── Plugin struct ─────────────────────────────────────────────────────────────
pub struct TelegramPlugin {
/// Bot token — set by reload() before start() is called.
token: Mutex<String>,
running: Arc<AtomicBool>,
cancel: Mutex<Option<CancellationToken>>,
handle: Mutex<Option<JoinHandle<()>>>,
/// Runtime shared state, populated by `start()`. Accessible to the pairing
/// tool so it can write bindings before/after the dispatcher is running.
shared: std::sync::OnceLock<Arc<TgShared>>,
}
impl TelegramPlugin {
pub fn new() -> Self {
Self {
token: Mutex::new(String::new()),
running: Arc::new(AtomicBool::new(false)),
cancel: Mutex::new(None),
handle: Mutex::new(None),
shared: std::sync::OnceLock::new(),
}
}
/// Returns the shared runtime state if the plugin is running.
pub(crate) fn shared(&self) -> Option<&Arc<TgShared>> {
self.shared.get()
}
}
#[async_trait]
impl Plugin for TelegramPlugin {
fn id(&self) -> &str { PLUGIN_ID }
fn name(&self) -> &str { "Telegram Bot" }
fn description(&self) -> &str {
"Private Telegram bot. Forwards messages to the LLM; supports HITL approval via inline keyboards."
}
fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) }
fn config_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"token": {
"type": "string",
"title": "Bot Token",
"description": "Telegram bot token from @BotFather",
"sensitive": true
}
},
"required": ["token"]
})
}
/// The user-facing pairing page (`#plugin/telegram/telegram`), served as a
/// fragment from this plugin's own router. Visible to any user with a
/// `plugin_access` grant — the correct audience for self-service pairing.
fn web_pages(&self) -> Vec<PluginPage> {
vec![PluginPage {
page_id: "telegram",
title: "Telegram".into(),
icon: "telegram",
entry: "web/telegram.js".into(),
admin_only: false,
// Sidebar priority: core "Your space" items live in 1090, mobile
// connector took 100, honcho 120130 — slot in between.
priority: 110,
}]
}
/// Serves the page fragment + its string table. Stateless and cheap to
/// build (the contract: routers are built at boot, enabled or not) — the
/// pairing save itself reuses the core `/api/plugins/telegram/my-config`
/// endpoint, so no runtime state is needed here.
fn http_router(&self) -> Option<axum::Router> {
use axum::{Router, routing::get, http::header, response::{IntoResponse, Response}};
fn serve_js(body: &'static str) -> Response {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], body).into_response()
}
Some(Router::new()
.route("/web/telegram.js", get(|| async { serve_js(include_str!("../web/telegram.js")) }))
.route("/web/i18n.js", get(|| async { serve_js(include_str!("../web/i18n.js")) })))
}
/// Self-service pairing: the user pastes the code the bot replied with,
/// we turn it into a `chat_id → user_id` binding (same write path as the
/// `telegram_pairing` tool) and store a status blob for the UI.
async fn update_user_config(&self, user_id: &str, config: Value, ctx: &PluginContext) -> Result<()> {
let code = config.get("pairing_code").and_then(Value::as_str).unwrap_or("").trim();
anyhow::ensure!(!code.is_empty(), "telegram: `pairing_code` is required");
let shared = self.shared()
.ok_or_else(|| anyhow::anyhow!("telegram: the bot is not running — ask the admin to check the plugin"))?
.clone();
let mut cfg = auth::load_config(&*shared.config).await.unwrap_or_default();
let chat_id = auth::apply_pairing_code(&mut cfg, code, user_id)?;
auth::save_config(&*shared.config, &cfg).await?;
ctx.user_config
.set(self.id(), user_id, json!({ "linked": true, "chat_id": chat_id }))
.await?;
info!(user_id, chat_id, "telegram: user self-paired via the web UI");
Ok(())
}
fn as_any(&self) -> &dyn std::any::Any { self }
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()> {
let new_token = config["token"].as_str().unwrap_or("").to_string();
let old_token = self.token.lock().await.clone();
let is_running = self.is_running();
match (enabled, is_running) {
(true, false) => {
anyhow::ensure!(!new_token.is_empty(),
"telegram: cannot start — `token` is missing from config");
*self.token.lock().await = new_token;
self.start(ctx).await?;
}
(false, true) => {
self.stop().await?;
}
(true, true) => {
if new_token != old_token {
info!("telegram: token changed — restarting");
self.stop().await?;
*self.token.lock().await = new_token;
self.start(ctx).await?;
}
}
(false, false) => {}
}
Ok(())
}
async fn start(&self, ctx: PluginContext) -> Result<()> {
if self.running.load(Ordering::Relaxed) {
return Ok(());
}
let token = self.token.lock().await.clone();
if token.is_empty() {
anyhow::bail!("telegram: token is empty — set it via the plugins API");
}
// Load bindings from the config table (or default if absent).
let telegram_config = auth::load_config(&*ctx.config).await
.unwrap_or_default();
info!(
bindings = telegram_config.bindings.len(),
pending = telegram_config.pending_pairings.len(),
"telegram: config loaded",
);
let shared = Arc::new(TgShared {
user_channel: Arc::clone(&ctx.user_channel),
command: Arc::clone(&ctx.command),
config: Arc::clone(&ctx.config),
transcribe: Arc::clone(&ctx.transcribe),
tts: Arc::clone(&ctx.tts_provider),
location: Arc::clone(&ctx.location),
bindings: RwLock::new(telegram_config),
pending_approvals: Mutex::new(HashMap::new()),
pending_questions: Mutex::new(HashMap::new()),
forwarders: Mutex::new(HashSet::new()),
});
let _ = self.shared.set(Arc::clone(&shared));
let bot = Bot::new(&token);
let cancel = CancellationToken::new();
// Config listener: reloads bindings when the "telegram" config key
// changes (e.g. the pairing tool writes a new binding).
{
let shared_c = Arc::clone(&shared);
let cancel_c = cancel.clone();
let bus_rx = ctx.system_bus.subscribe();
tokio::spawn(auth::config_listener(shared_c, bus_rx, cancel_c));
}
// Spawn forwarders for already-unlocked paired users.
{
let shared_c = Arc::clone(&shared);
let bot_c = bot.clone();
let cancel_c = cancel.clone();
tokio::spawn(async move {
events::spawn_forwarders_for_bound_users(&bot_c, &shared_c, &cancel_c).await;
});
}
let cancel_clone = cancel.clone();
let running_clone = Arc::clone(&self.running);
self.running.store(true, Ordering::Relaxed);
let handler = dptree::entry()
.branch(Update::filter_message().endpoint(handlers::message_handler))
.branch(Update::filter_edited_message().endpoint(handlers::edited_message_handler))
.branch(Update::filter_callback_query().endpoint(events::callback_handler));
let task = tokio::spawn(async move {
let mut dispatcher = Dispatcher::builder(bot, handler)
.dependencies(dptree::deps![shared])
.build();
info!("telegram plugin: dispatcher starting");
tokio::select! {
_ = cancel_clone.cancelled() => info!("telegram plugin: cancellation received"),
_ = dispatcher.dispatch() => warn!("telegram plugin: dispatcher exited unexpectedly"),
}
running_clone.store(false, Ordering::Relaxed);
info!("telegram plugin: stopped");
});
*self.cancel.lock().await = Some(cancel);
*self.handle.lock().await = Some(task);
Ok(())
}
async fn stop(&self) -> Result<()> {
if let Some(token) = self.cancel.lock().await.take() {
token.cancel();
}
if let Some(h) = self.handle.lock().await.take() {
let _ = h.await;
}
self.running.store(false, Ordering::Relaxed);
Ok(())
}
fn tools(self: Arc<Self>) -> Vec<Arc<dyn core_api::tool::Tool>> {
vec![Arc::new(tools::TelegramPairingTool::new(self))]
}
}