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:
2026-07-19 20:47:09 +01:00
parent f85876350e
commit ba911ae8cb
50 changed files with 3186 additions and 305 deletions
+80 -1
View File
@@ -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());
}
}
+5
View File
@@ -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,
+54 -5
View File
@@ -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 }