telegram bot user isolation, config store, user context channels
This commit is contained in:
@@ -1,79 +1,91 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use rand::RngExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::ParseMode;
|
||||
use tokio::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use core_api::config_api::ConfigApi;
|
||||
use core_api::system_bus::SystemEvent;
|
||||
|
||||
use super::TgShared;
|
||||
|
||||
// ── Whitelist file schema ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Written to secrets/telegram_whitelist.json.
|
||||
// The main agent edits this file directly to authorise users.
|
||||
/// Config-table key under which all Telegram bindings are stored as JSON.
|
||||
pub(crate) const CONFIG_KEY: &str = "telegram";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct WhitelistFile {
|
||||
// ── Bindings schema (stored as JSON in the `config` table) ────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
pub struct TelegramConfig {
|
||||
#[serde(default)]
|
||||
pub whitelist: Vec<i64>,
|
||||
pub bindings: Vec<Binding>,
|
||||
#[serde(default)]
|
||||
pub pending_pairings: Vec<PairingEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PairingEntry {
|
||||
pub code: String,
|
||||
pub chat_id: i64,
|
||||
pub issued_at: String,
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Binding {
|
||||
pub chat_id: i64,
|
||||
pub user_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn load_wl(secrets_dir: &Path) -> WhitelistFile {
|
||||
let path = secrets_dir.join("telegram_whitelist.json");
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => WhitelistFile::default(),
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PairingEntry {
|
||||
pub code: String,
|
||||
pub chat_id: i64,
|
||||
pub issued_at: String,
|
||||
}
|
||||
|
||||
// ── Config-table read/write ────────────────────────────────────────────────────
|
||||
|
||||
/// Reads the Telegram config from the `config` table. Returns `Default` when
|
||||
/// the key is absent or unparseable (never fails the caller).
|
||||
pub(crate) async fn load_config(config: &dyn ConfigApi) -> anyhow::Result<TelegramConfig> {
|
||||
match config.get(CONFIG_KEY).await? {
|
||||
Some(json) => Ok(serde_json::from_str(&json).unwrap_or_default()),
|
||||
None => Ok(TelegramConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn save_wl(secrets_dir: &Path, wl: &WhitelistFile) -> Result<()> {
|
||||
tokio::fs::create_dir_all(secrets_dir).await?;
|
||||
let path = secrets_dir.join("telegram_whitelist.json");
|
||||
tokio::fs::write(&path, serde_json::to_string_pretty(wl)?).await?;
|
||||
Ok(())
|
||||
/// Writes the Telegram config to the `config` table. `ConfigApi::set` emits a
|
||||
/// `ConfigKeyUpdated` event when the value changes, so the in-memory cache and
|
||||
/// any forwarders are updated automatically.
|
||||
pub(crate) async fn save_config(
|
||||
config: &dyn ConfigApi,
|
||||
cfg: &TelegramConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
config.set(CONFIG_KEY, &serde_json::to_string_pretty(cfg)?).await
|
||||
}
|
||||
|
||||
// ── Pairing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Pairing codes older than this are considered abandoned and pruned, so the
|
||||
/// whitelist file does not accumulate stale `pending_pairings` entries.
|
||||
/// Pairing codes older than this are considered abandoned and pruned.
|
||||
const PAIRING_TTL_HOURS: i64 = 24;
|
||||
|
||||
/// Called when an unbound `chat_id` sends a message. Generates (or reuses) a
|
||||
/// pairing code, persists it to the config table, and replies with instructions.
|
||||
pub(crate) async fn handle_pairing(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
|
||||
let mut wl = load_wl(&shared.secrets_dir).await;
|
||||
let mut cfg = shared.bindings.read().await.clone();
|
||||
|
||||
// Drop pairing codes past their TTL. Entries with an unparseable timestamp
|
||||
// are kept (don't silently lose data on a format change).
|
||||
// Prune expired codes.
|
||||
let cutoff = Utc::now() - chrono::Duration::hours(PAIRING_TTL_HOURS);
|
||||
let before = wl.pending_pairings.len();
|
||||
wl.pending_pairings.retain(|e| match DateTime::parse_from_rfc3339(&e.issued_at) {
|
||||
cfg.pending_pairings.retain(|e| match DateTime::parse_from_rfc3339(&e.issued_at) {
|
||||
Ok(ts) => ts.with_timezone(&Utc) > cutoff,
|
||||
Err(_) => true,
|
||||
});
|
||||
let pruned = wl.pending_pairings.len() != before;
|
||||
|
||||
// Re-use an existing (non-expired) code if one is already pending for this chat.
|
||||
let (code, added) = if let Some(entry) = wl.pending_pairings.iter().find(|e| e.chat_id == chat_id.0) {
|
||||
// Reuse an existing code for this chat, or generate a new one.
|
||||
let (code, added) = if let Some(entry) = cfg.pending_pairings.iter().find(|e| e.chat_id == chat_id.0) {
|
||||
(entry.code.clone(), false)
|
||||
} else {
|
||||
let code = generate_code();
|
||||
wl.pending_pairings.push(PairingEntry {
|
||||
cfg.pending_pairings.push(PairingEntry {
|
||||
code: code.clone(),
|
||||
chat_id: chat_id.0,
|
||||
issued_at: Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string(),
|
||||
@@ -81,14 +93,16 @@ pub(crate) async fn handle_pairing(bot: &Bot, chat_id: ChatId, shared: &Arc<TgSh
|
||||
(code, true)
|
||||
};
|
||||
|
||||
// Persist if we added a new code or pruned expired ones.
|
||||
if added || pruned {
|
||||
if let Err(e) = save_wl(&shared.secrets_dir, &wl).await {
|
||||
error!(error = %e, "telegram: failed to write whitelist file");
|
||||
}
|
||||
}
|
||||
if added {
|
||||
info!(chat_id = chat_id.0, code = %code, "TELEGRAM PAIRING: code written to telegram_whitelist.json");
|
||||
if let Err(e) = save_config(&*shared.config, &cfg).await {
|
||||
error!(error = %e, "telegram: failed to write pairing to config table");
|
||||
} else {
|
||||
// Update the in-memory cache immediately (the config_listener will
|
||||
// also fire, but this avoids a race if the user sends another
|
||||
// message before the event arrives).
|
||||
*shared.bindings.write().await = cfg.clone();
|
||||
}
|
||||
info!(chat_id = chat_id.0, code = %code, "TELEGRAM PAIRING: code written to config table");
|
||||
}
|
||||
|
||||
bot.send_message(
|
||||
@@ -96,7 +110,7 @@ pub(crate) async fn handle_pairing(bot: &Bot, chat_id: ChatId, shared: &Arc<TgSh
|
||||
format!(
|
||||
"🔐 <b>Pairing required.</b>\n\n\
|
||||
Code: <code>{code}</code>\n\n\
|
||||
Provide this code to the web agent to authorize access.",
|
||||
Ask the admin to authorize this chat using the telegram_pairing tool.",
|
||||
),
|
||||
)
|
||||
.parse_mode(ParseMode::Html)
|
||||
@@ -110,60 +124,38 @@ pub(crate) fn generate_code() -> String {
|
||||
(0..6).map(|_| CHARS[rng.random_range(0..CHARS.len())] as char).collect()
|
||||
}
|
||||
|
||||
// ── Whitelist watchdog ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Polls telegram_whitelist.json every 10 s for mtime changes.
|
||||
// When a new chat_id appears in `whitelist` (agent moved it from pending),
|
||||
// sends a welcome message so the user knows they are authorized.
|
||||
|
||||
pub(crate) async fn whitelist_watchdog(bot: Bot, secrets_dir: PathBuf, cancel: CancellationToken) {
|
||||
let path = secrets_dir.join("telegram_whitelist.json");
|
||||
|
||||
let mut last_mtime: Option<SystemTime> = tokio::fs::metadata(&path).await.ok()
|
||||
.and_then(|m| m.modified().ok());
|
||||
let mut known_wl = load_wl(&secrets_dir).await.whitelist;
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
||||
interval.tick().await; // skip the immediate first tick
|
||||
// ── Config listener ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Subscribes to the system bus and reloads the in-memory bindings whenever the
|
||||
/// `"telegram"` config key changes. Replaces the old file-polling watchdog.
|
||||
pub(crate) async fn config_listener(
|
||||
shared: Arc<TgShared>,
|
||||
mut rx: broadcast::Receiver<SystemEvent>,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
info!("telegram: config listener started");
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = interval.tick() => {
|
||||
let new_mtime = tokio::fs::metadata(&path).await.ok()
|
||||
.and_then(|m| m.modified().ok());
|
||||
if new_mtime.is_none() || new_mtime == last_mtime {
|
||||
continue;
|
||||
}
|
||||
last_mtime = new_mtime;
|
||||
|
||||
let wl = load_wl(&secrets_dir).await;
|
||||
let newly_authorized: Vec<i64> = wl.whitelist.iter()
|
||||
.filter(|id| !known_wl.contains(id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if !newly_authorized.is_empty() {
|
||||
info!(users = ?newly_authorized, "telegram: new users authorized — sending welcome");
|
||||
for &chat_id in &newly_authorized {
|
||||
bot.send_message(
|
||||
ChatId(chat_id),
|
||||
"✅ <b>Access granted!</b>\n\
|
||||
You can now talk to your agent.\n\n\
|
||||
/help for available commands.",
|
||||
)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
_ = cancel.cancelled() => {
|
||||
info!("telegram: config listener stopped");
|
||||
return;
|
||||
}
|
||||
result = rx.recv() => match result {
|
||||
Ok(SystemEvent::ConfigKeyUpdated { key, new_value, .. }) if key == CONFIG_KEY => {
|
||||
match serde_json::from_str::<TelegramConfig>(&new_value) {
|
||||
Ok(cfg) => {
|
||||
let n = cfg.bindings.len();
|
||||
*shared.bindings.write().await = cfg;
|
||||
info!(bindings = n, "telegram: bindings reloaded from config event");
|
||||
}
|
||||
Err(e) => warn!(error = %e, "telegram: failed to parse config from event"),
|
||||
}
|
||||
}
|
||||
|
||||
known_wl = wl.whitelist;
|
||||
info!(
|
||||
whitelist = known_wl.len(),
|
||||
pending = wl.pending_pairings.len(),
|
||||
"telegram: whitelist file reloaded"
|
||||
);
|
||||
Ok(_) => {}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(skipped = n, "telegram: config listener lagged");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user