feat(plugins): plugin pages, per-user config, capabilities gate, mobile/telegram refactors
- Plugin HTTP routes + web pages (plugin-page-host, plugin-catalog, plugin-detail) - Plugin access grants + per-user config (DB tables + API + frontend forms) - Capabilities-based guard (caps.rs) replacing role-id checks - Mobile connector: message routing, payload types, router refactor - Telegram bot: auth flow, event handling improvements - Honcho plugin: substantial rework - Sidebar: plugin pages integration, role-driven visibility - i18n: new strings for plugins, connectors, capabilities - Remove unused mascot asset
This commit is contained in:
@@ -110,7 +110,8 @@ 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\
|
||||
Ask the admin to authorize this chat using the telegram_pairing tool.",
|
||||
Open the Plugins page in the Skald web app and paste this code \
|
||||
to link your account (or ask the admin).",
|
||||
),
|
||||
)
|
||||
.parse_mode(ParseMode::Html)
|
||||
@@ -124,6 +125,29 @@ pub(crate) fn generate_code() -> String {
|
||||
(0..6).map(|_| CHARS[rng.random_range(0..CHARS.len())] as char).collect()
|
||||
}
|
||||
|
||||
/// Turns a pairing code into a binding for `user_id` (the web self-service
|
||||
/// flow — `Plugin::update_user_config`). Mirrors the `telegram_pairing` tool's
|
||||
/// bind semantics: the pending entry is consumed and any existing binding for
|
||||
/// that chat is replaced. Returns the bound `chat_id`.
|
||||
pub(crate) fn apply_pairing_code(
|
||||
cfg: &mut TelegramConfig,
|
||||
code: &str,
|
||||
user_id: &str,
|
||||
) -> anyhow::Result<i64> {
|
||||
let code = code.trim();
|
||||
let pos = cfg.pending_pairings.iter()
|
||||
.position(|e| e.code.eq_ignore_ascii_case(code))
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid or expired pairing code — send a message to the bot to get a new one"))?;
|
||||
let chat_id = cfg.pending_pairings.remove(pos).chat_id;
|
||||
cfg.bindings.retain(|b| b.chat_id != chat_id);
|
||||
cfg.bindings.push(Binding {
|
||||
chat_id,
|
||||
user_id: user_id.to_string(),
|
||||
display: None,
|
||||
});
|
||||
Ok(chat_id)
|
||||
}
|
||||
|
||||
// ── Config listener ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Subscribes to the system bus and reloads the in-memory bindings whenever the
|
||||
@@ -160,3 +184,58 @@ pub(crate) async fn config_listener(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg_with_pairing(code: &str, chat_id: i64) -> TelegramConfig {
|
||||
TelegramConfig {
|
||||
bindings: vec![],
|
||||
pending_pairings: vec![PairingEntry {
|
||||
code: code.to_string(),
|
||||
chat_id,
|
||||
issued_at: "2026-01-01T00:00:00+00:00".to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_code_creates_binding_and_consumes_entry() {
|
||||
let mut cfg = cfg_with_pairing("ABC123", 42);
|
||||
let chat_id = apply_pairing_code(&mut cfg, "ABC123", "u1").unwrap();
|
||||
assert_eq!(chat_id, 42);
|
||||
assert!(cfg.pending_pairings.is_empty(), "the code must be consumed");
|
||||
assert_eq!(cfg.bindings.len(), 1);
|
||||
assert_eq!(cfg.bindings[0].user_id, "u1");
|
||||
assert_eq!(cfg.bindings[0].chat_id, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_code_is_case_and_whitespace_insensitive() {
|
||||
let mut cfg = cfg_with_pairing("ABC123", 42);
|
||||
apply_pairing_code(&mut cfg, " abc123 ", "u1").unwrap();
|
||||
assert_eq!(cfg.bindings[0].user_id, "u1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_replaces_an_existing_binding_for_the_same_chat() {
|
||||
let mut cfg = cfg_with_pairing("ABC123", 42);
|
||||
cfg.bindings.push(Binding { chat_id: 42, user_id: "old".into(), display: None });
|
||||
cfg.bindings.push(Binding { chat_id: 99, user_id: "other".into(), display: None });
|
||||
apply_pairing_code(&mut cfg, "ABC123", "u1").unwrap();
|
||||
assert_eq!(cfg.bindings.len(), 2);
|
||||
assert!(cfg.bindings.iter().any(|b| b.chat_id == 42 && b.user_id == "u1"));
|
||||
assert!(cfg.bindings.iter().any(|b| b.chat_id == 99 && b.user_id == "other"),
|
||||
"bindings for other chats are untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_code_fails_and_keeps_state() {
|
||||
let mut cfg = cfg_with_pairing("ABC123", 42);
|
||||
let err = apply_pairing_code(&mut cfg, "ZZZ999", "u1").unwrap_err();
|
||||
assert!(err.to_string().contains("invalid or expired"));
|
||||
assert_eq!(cfg.pending_pairings.len(), 1, "the pending entry must survive a failed attempt");
|
||||
assert!(cfg.bindings.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ pub(crate) async fn spawn_forwarders_for_bound_users(
|
||||
) {
|
||||
let bindings = shared.bindings.read().await.clone();
|
||||
for b in &bindings.bindings {
|
||||
// Skip users whose access was revoked — don't spin up a forwarder for
|
||||
// a chat the bot will refuse to serve anyway (inbound is gated too).
|
||||
if !shared.user_authorized(&b.user_id).await {
|
||||
continue;
|
||||
}
|
||||
if let Some(handle) = shared.user_channel.resolve_user(&b.user_id).await {
|
||||
ensure_forwarder(bot.clone(), Arc::clone(shared), &b.user_id, b.chat_id, handle, cancel.clone()).await;
|
||||
}
|
||||
|
||||
@@ -124,6 +124,19 @@ pub(crate) async fn message_handler(
|
||||
}
|
||||
};
|
||||
|
||||
// The chat is bound, but access is a separate admin-revocable grant. Gate
|
||||
// here so a revoked user is refused immediately, without touching the
|
||||
// binding (a re-grant restores service with no re-pairing).
|
||||
if !shared.user_authorized(&user_id).await {
|
||||
bot.send_message(
|
||||
chat_id,
|
||||
"⛔ Your access to this bot has been withdrawn by an administrator.",
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
///
|
||||
/// # Pairing
|
||||
///
|
||||
/// Unknown chats receive a pairing code. The admin's agent calls the
|
||||
/// `telegram_pairing` tool (category `Config`) to bind the `chat_id` to a
|
||||
/// `user_id`. The binding is written to the config table; the resulting
|
||||
/// `ConfigKeyUpdated` event reloads the in-memory cache instantly.
|
||||
/// Unknown chats receive a pairing code. The user links their own account by
|
||||
/// pasting the code in the Plugins page of the web app (the plugin's
|
||||
/// `user_config_schema` / `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
|
||||
///
|
||||
@@ -53,6 +55,11 @@ 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 = "\
|
||||
@@ -127,6 +134,15 @@ impl TgShared {
|
||||
.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 ─────────────────────────────────────────────────────────────
|
||||
@@ -161,7 +177,7 @@ impl TelegramPlugin {
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for TelegramPlugin {
|
||||
fn id(&self) -> &str { "telegram" }
|
||||
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."
|
||||
@@ -183,6 +199,39 @@ impl Plugin for TelegramPlugin {
|
||||
})
|
||||
}
|
||||
|
||||
fn user_config_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pairing_code": {
|
||||
"type": "string",
|
||||
"title": "Pairing code",
|
||||
"description": "Send any message to the bot — it replies with a 6-character code. Paste it here to link your Telegram chat."
|
||||
}
|
||||
},
|
||||
"required": ["pairing_code"]
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user