plugins: merge the user Plugins page into per-plugin sidebar pages
Nightly Build / build (push) Successful in 7m1s

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.
This commit is contained in:
2026-07-28 20:48:03 +01:00
parent 50e1333d99
commit 4b1affa600
32 changed files with 338 additions and 371 deletions
+10 -12
View File
@@ -120,16 +120,14 @@ pub trait Plugin: Send + Sync {
/// JSON Schema describing the plugin's config fields.
fn config_schema(&self) -> Value { serde_json::json!({}) }
/// JSON Schema describing the plugin's *per-user* config fields (e.g.
/// Telegram's pairing code). Empty schema (the default) = the plugin has
/// no per-user settings and does not appear as configurable in the user
/// UI. Values are stored admin-readable in `system.db` — never secrets.
fn user_config_schema(&self) -> Value { serde_json::json!({}) }
/// Applies a per-user config submission. The default just stores the blob
/// in the generic store; plugins that need validation or a side effect
/// (e.g. Telegram turning a pairing code into a chat binding) override it
/// and may store a sanitized status blob for the UI via `ctx.user_config`.
/// Applies a per-user config submission, received through the core
/// `PUT /api/plugins/{id}/my-config` endpoint from the plugin's own
/// [`Plugin::web_pages`] fragment (e.g. Telegram's pairing page, Honcho's
/// opt-in page). The default just stores the blob in the generic store;
/// plugins that need validation or a side effect (e.g. Telegram turning a
/// pairing code into a chat binding) override it and may store a sanitized
/// status blob for the UI via `ctx.user_config`. Values are stored
/// admin-readable in `system.db` — never secrets.
async fn update_user_config(&self, user_id: &str, config: Value, ctx: &PluginContext) -> Result<()> {
ctx.user_config.set(self.id(), user_id, config).await
}
@@ -138,8 +136,8 @@ pub trait Plugin: Send + Sync {
/// pairing lifecycle rather than the generic `plugin_access` grants — e.g.
/// the mobile connector, whose access is the device→user binding (§13).
/// When `true`, the admin Plugins UI suppresses the "User access"
/// checklist (it would control nothing), the plugin never appears in a
/// user's "My plugins" view, and its non-`admin_only` `web_pages()` are
/// checklist (it would control nothing), the plugin is left out of
/// `GET /api/plugins/mine`, and its non-`admin_only` `web_pages()` are
/// visible to every logged-in user — the page itself scopes what each
/// caller sees (e.g. admin sees all devices, others only their own).
/// Default `false`: access is the admin's per-user `plugin_access` grant
+1 -1
View File
@@ -6,7 +6,7 @@ use serde_json::Value;
/// `system.db`).
///
/// Values are deliberately admin-readable — the table lives in the registry
/// database — so `user_config_schema`s must never collect secrets. A plugin
/// database — so per-user plugin configs must never collect secrets. A plugin
/// that needs per-user secrets should keep them elsewhere.
#[async_trait]
pub trait PluginUserConfigApi: Send + Sync {
-21
View File
@@ -829,27 +829,6 @@ impl core_api::plugin::Plugin for HonchoPlugin {
})
}
/// Per-user opt-in. Honcho stores conversations in cleartext on an external
/// server, so a user must knowingly enable it. A plain boolean — no secrets —
/// so the admin-readable `plugin_user_configs` store is an honest home. The
/// default `update_user_config` (store the blob) is exactly right; no override.
fn user_config_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"title": "Enable long-term memory",
"description": "Let the assistant remember you across sessions. \
Your messages will be stored in cleartext on the \
Honcho memory server, outside your encrypted \
database. Off unless you turn it on.",
"default": false
}
}
})
}
/// Two dedicated pages served from this plugin's own router (`web/*.js`):
/// an **admin** config page (connection + a connectivity test) and a
/// **user** opt-in page (the per-user consent to long-term memory). The
+1
View File
@@ -7,6 +7,7 @@ edition = "2024"
core-api = { path = "../core-api" }
anyhow = "1"
async-trait = "0.1"
axum = { version = "0.8" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
+36 -18
View File
@@ -12,11 +12,13 @@
/// # Pairing
///
/// 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.
/// 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
///
@@ -42,7 +44,7 @@ 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};
use core_api::plugin::{Plugin, PluginContext, PluginPage};
use core_api::transcribe::TranscribeProvider;
use core_api::tts::TtsProvider;
use core_api::user_channel::UserChannelApi;
@@ -197,18 +199,34 @@ 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"]
})
/// 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,
+57
View File
@@ -0,0 +1,57 @@
// Frontend translations for the Telegram page fragment.
//
// Served at `/api/plugin/telegram/web/i18n.js` and imported by `telegram.js`,
// which registers it into the host's shared dictionaries via `addStrings`
// (see `web/lib/i18n.js`). Keys are namespaced `plugin.telegram.*` so they
// never collide with core keys.
const P = 'plugin.telegram';
export default {
en: {
[`${P}.title`]: 'Telegram',
[`${P}.intro`]: 'Chat with the assistant from Telegram by linking your Telegram chat to your account.',
[`${P}.status.linked`]: 'Your Telegram chat is linked.',
[`${P}.status.unlinked`]: 'Your Telegram chat is not linked yet.',
[`${P}.status.chat_id`]: 'Chat ID',
[`${P}.howto_title`]: 'How to link it',
[`${P}.howto_body`]: 'Send any message to the bot — it replies with a 6-character code. Paste the code here.',
[`${P}.code_label`]: 'Pairing code',
[`${P}.save`]: 'Link',
[`${P}.saved`]: 'Linked!',
[`${P}.relink_hint`]: 'Pasting a new code replaces the current link.',
[`${P}.loading`]: 'Loading…',
[`${P}.unavailable`]: 'Telegram is not available to you yet. Ask your administrator to grant access.',
},
it: {
[`${P}.title`]: 'Telegram',
[`${P}.intro`]: 'Chatta con lassistente da Telegram collegando la tua chat Telegram al tuo account.',
[`${P}.status.linked`]: 'La tua chat Telegram è collegata.',
[`${P}.status.unlinked`]: 'La tua chat Telegram non è ancora collegata.',
[`${P}.status.chat_id`]: 'ID chat',
[`${P}.howto_title`]: 'Come collegarla',
[`${P}.howto_body`]: 'Invia un messaggio qualsiasi al bot — ti risponde con un codice di 6 caratteri. Incolla il codice qui.',
[`${P}.code_label`]: 'Codice di pairing',
[`${P}.save`]: 'Collega',
[`${P}.saved`]: 'Collegata!',
[`${P}.relink_hint`]: 'Incollare un nuovo codice sostituisce il collegamento attuale.',
[`${P}.loading`]: 'Caricamento…',
[`${P}.unavailable`]: 'Telegram non è ancora disponibile per te. Chiedi allamministratore di darti laccesso.',
},
fr: {
[`${P}.title`]: 'Telegram',
[`${P}.intro`]: 'Discutez avec lassistant depuis Telegram en reliant votre conversation Telegram à votre compte.',
[`${P}.status.linked`]: 'Votre conversation Telegram est reliée.',
[`${P}.status.unlinked`]: 'Votre conversation Telegram nest pas encore reliée.',
[`${P}.status.chat_id`]: 'ID de conversation',
[`${P}.howto_title`]: 'Comment la relier',
[`${P}.howto_body`]: 'Envoyez nimporte quel message au bot — il répond avec un code à 6 caractères. Collez le code ici.',
[`${P}.code_label`]: 'Code dappairage',
[`${P}.save`]: 'Relier',
[`${P}.saved`]: 'Reliée !',
[`${P}.relink_hint`]: 'Coller un nouveau code remplace le lien actuel.',
[`${P}.loading`]: 'Chargement…',
[`${P}.unavailable`]: 'Telegram nest pas encore disponible pour vous. Demandez laccès à votre administrateur.',
},
};
+159
View File
@@ -0,0 +1,159 @@
// Telegram pairing page (page_id `telegram`, visible to any user with a
// `plugin_access` grant).
//
// Self-service chat linking: the user sends any message to the bot, gets a
// 6-character code back, and pastes it here. Reuses the core per-user config
// endpoints — `GET /api/plugins/mine` to read the `{linked, chat_id}` status
// blob, `PUT /api/plugins/telegram/my-config` to submit the code (the
// plugin's `update_user_config` override turns it into a chat↔user binding) —
// so this fragment needs no backend of its own. Default-exports the element
// class; the host registers it.
import { LitElement, html, nothing } from 'lit';
import { t, addStrings, I18nMixin } from '/lib/i18n.js';
import STRINGS from './i18n.js';
addStrings(STRINGS);
const P = 'plugin.telegram';
const ID = 'telegram';
/// JSON fetch that throws the server's error text on non-2xx and tolerates an
/// empty (204) body. The server's error text is already localized, so it is
/// safe to surface directly.
async function jf(url, opts = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
...opts,
});
if (!res.ok) {
const txt = await res.text().catch(() => '');
throw new Error(txt || `HTTP ${res.status}`);
}
if (res.status === 204) return null;
const ct = res.headers.get('content-type') || '';
return ct.includes('application/json') ? res.json() : res.text();
}
export default class TelegramPage extends I18nMixin(LitElement) {
// Light DOM, so Bootstrap classes and the app's theme CSS variables apply.
createRenderRoot() { return this; }
static get properties() {
return {
_row: { state: true }, // UserPluginView | null (null once loaded = not granted)
_code: { state: true }, // pairing code draft
_status: { state: true }, // { ok?, err? }
_error: { state: true },
_loading: { state: true },
};
}
constructor() {
super();
this._row = null;
this._code = '';
this._status = {};
this._error = null;
this._loading = true;
}
connectedCallback() {
super.connectedCallback();
this._load();
}
async _load() {
this._loading = true;
this._error = null;
try {
const mine = await jf('/api/plugins/mine');
this._row = (mine ?? []).find(x => x.id === ID) ?? null;
} catch (e) {
this._error = e.message;
} finally {
this._loading = false;
}
}
async _save() {
this._status = {};
try {
await jf(`/api/plugins/${ID}/my-config`, {
method: 'PUT',
body: JSON.stringify({ pairing_code: this._code.trim() }),
});
this._code = '';
this._status = { ok: t(`${P}.saved`) };
await this._load();
} catch (e) {
this._status = { err: e.message };
}
}
render() {
return html`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-telegram me-2"></i>${t(`${P}.title`)}</h2>
</div>
<div style="padding:0 1.25rem 2rem; max-width:640px; overflow:auto">
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
${this._loading
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t(`${P}.loading`)}</div>`
: this._row ? this._renderBody() : this._renderUnavailable()}
</div>
</div>`;
}
_renderUnavailable() {
return html`
<div class="um-empty" style="padding:1rem">
<i class="bi bi-shield-lock"></i>
<p>${t(`${P}.unavailable`)}</p>
</div>`;
}
_renderBody() {
const linked = !!this._row?.user_config?.linked;
const chatId = this._row?.user_config?.chat_id;
return html`
<p class="text-body-secondary" style="font-size:.9rem">${t(`${P}.intro`)}</p>
<div class="connector-card" style="cursor:default">
<div class="connector-card-head">
<div class="connector-card-icon connector-card-icon--empty"><i class="bi bi-telegram"></i></div>
<div class="connector-card-title">
<div class="connector-card-name" style="font-size:.9rem">
${linked ? t(`${P}.status.linked`) : t(`${P}.status.unlinked`)}
</div>
${linked && chatId != null ? html`
<div class="connector-card-sub">${t(`${P}.status.chat_id`)}: ${chatId}</div>` : nothing}
</div>
<span class="connector-chip ${linked ? 'connector-chip--ok' : ''}">
${linked ? html`<i class="bi bi-check-lg"></i>` : html`<i class="bi bi-dash-lg"></i>`}
</span>
</div>
</div>
<div class="mt-4" style="font-size:.85rem; font-weight:600">
<i class="bi bi-link-45deg me-1"></i>${t(`${P}.howto_title`)}
</div>
<p class="text-body-secondary" style="font-size:.82rem">${t(`${P}.howto_body`)}</p>
<div class="mb-3" style="max-width:280px">
<label class="form-label" style="font-size:.8rem">${t(`${P}.code_label`)}</label>
<input class="form-control form-control-sm" type="text" .value=${this._code}
@input=${(e) => { this._code = e.target.value; this._status = {}; }} />
</div>
${this._status.err ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.82rem">${this._status.err}</div>` : nothing}
${this._status.ok ? html`<div class="alert alert-success py-2 mb-3" style="font-size:.82rem">${this._status.ok}</div>` : nothing}
<button class="btn btn-primary btn-sm" ?disabled=${!this._code.trim()} @click=${() => this._save()}>
<i class="bi bi-check-lg me-1"></i>${t(`${P}.save`)}
</button>
${linked ? html`
<div class="text-body-secondary mt-2" style="font-size:.75rem">${t(`${P}.relink_hint`)}</div>` : nothing}
`;
}
}
@@ -1,10 +1,11 @@
//! Per-user plugin configuration blobs (`plugin_user_configs` table).
//!
//! Registry table in `system.db` — **admin-readable, never secrets**. A plugin
//! with a non-empty `user_config_schema()` lets each granted user submit their
//! own settings from the UI (e.g. Telegram's pairing code); the plugin's
//! `update_user_config` hook validates and stores here. `plugin_id` is a bare
//! TEXT for the same reason as `plugin_access`.
//! with per-user settings surfaces them in its own `web_pages()` fragment
//! (e.g. Telegram's pairing page); the submission travels through the core
//! `PUT /api/plugins/{id}/my-config` endpoint into the plugin's
//! `update_user_config` hook, which validates and stores here. `plugin_id` is
//! a bare TEXT for the same reason as `plugin_access`.
use anyhow::Result;
use serde_json::Value;
+23 -21
View File
@@ -34,7 +34,6 @@ pub struct PluginInfo {
pub running: bool,
pub config: Value,
pub config_schema: Value,
pub user_config_schema: Value,
/// Whether the plugin contributes an `http_router()` — its routes are
/// mounted at boot and gated at runtime, so they serve as soon as the
/// plugin is enabled (no restart).
@@ -42,6 +41,10 @@ pub struct PluginInfo {
/// Whether the plugin gates access through its own binding lifecycle — the
/// admin UI hides the "User access" checklist when true (see the trait).
pub manages_own_access: bool,
/// Whether the plugin contributes a user-facing (`!admin_only`) page via
/// `web_pages()` — the static signal that it has per-user settings of its
/// own (e.g. Telegram's pairing page). Informational, for the admin UI.
pub has_user_page: bool,
/// Whether the plugin-detail page shows the generic `config_schema` form
/// (`false` = the plugin hosts its own config UI in one of its pages).
pub config_in_detail_page: bool,
@@ -49,13 +52,14 @@ pub struct PluginInfo {
}
/// One user's view of a plugin they may use — served by `GET /api/plugins/mine`.
/// Read by the plugin's own page fragment (e.g. Telegram's pairing page reads
/// its `{linked, chat_id}` status blob from `user_config`).
#[derive(Debug, Clone, Serialize)]
pub struct UserPluginView {
pub id: String,
pub name: String,
pub description: String,
pub user_config_schema: Value,
pub user_config: Value,
pub id: String,
pub name: String,
pub description: String,
pub user_config: Value,
}
/// A plugin-contributed web page as seen by one user — served by
@@ -404,9 +408,9 @@ impl PluginManager {
running: plugin.is_running(),
config: serde_json::from_str(&config_json).unwrap_or(json!({})),
config_schema: plugin.config_schema(),
user_config_schema: plugin.user_config_schema(),
has_router: plugin.http_router().is_some(),
manages_own_access: plugin.manages_own_access(),
has_user_page: plugin.web_pages().iter().any(|pg| !pg.admin_only),
config_in_detail_page: plugin.config_in_detail_page(),
runtime_status: plugin.runtime_status(),
});
@@ -423,9 +427,10 @@ impl PluginManager {
// ── Per-user access & configuration ───────────────────────────────────────
/// The plugins a user sees in their UI: **enabled** and granted in
/// The plugins a user may interact with: **enabled** and granted in
/// `plugin_access` (admins see every enabled plugin). Each entry carries
/// the user's current config blob for the schema-driven form.
/// the user's current config blob — read by the plugin's own page
/// fragment, never rendered by a generic core UI.
pub async fn list_accessible(&self, user_id: &str, is_admin: bool) -> Result<Vec<UserPluginView>> {
let granted: std::collections::HashSet<String> = if is_admin {
std::collections::HashSet::new()
@@ -434,8 +439,8 @@ impl PluginManager {
};
let mut out = Vec::new();
for plugin in &self.plugins {
// Binding-managed plugins (e.g. mobile-connector) aren't configured
// from the "My plugins" view — they own their own pairing UI.
// Binding-managed plugins (e.g. mobile-connector) own their access
// model — there is no per-user config blob of ours to show them.
if plugin.manages_own_access() {
continue;
}
@@ -449,10 +454,9 @@ impl PluginManager {
.await?
.unwrap_or(json!({}));
out.push(UserPluginView {
id: plugin.id().to_string(),
name: plugin.name().to_string(),
description: plugin.description().to_string(),
user_config_schema: plugin.user_config_schema(),
id: plugin.id().to_string(),
name: plugin.name().to_string(),
description: plugin.description().to_string(),
user_config,
});
}
@@ -529,17 +533,15 @@ impl PluginManager {
plugin_access::set_access(&self.db, id, user_ids).await
}
/// Applies a user's per-plugin config submission. The plugin must be
/// enabled, declare a non-empty `user_config_schema`, and the caller must
/// hold access (enforced by the API layer).
/// Applies a user's per-plugin config submission, received from the
/// plugin's own page fragment. The plugin must be enabled and the caller
/// must hold access (enforced by the API layer); what the submission means
/// is entirely the plugin's business (see `Plugin::update_user_config`).
pub async fn update_user_config(&self, id: &str, user_id: &str, config: Value) -> Result<()> {
let plugin = self.find(id)?;
if !self.is_enabled(id).await? {
anyhow::bail!("plugin is not enabled: {id}");
}
if plugin.user_config_schema().as_object().is_none_or(|s| s.is_empty()) {
anyhow::bail!("plugin has no per-user configuration: {id}");
}
let skald = self.skald()?;
plugin.update_user_config(user_id, config, &self.build_context(&skald)?).await
}