plugins: merge the user Plugins page into per-plugin sidebar pages
Nightly Build / build (push) Successful in 7m1s
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:
@@ -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"] }
|
||||
|
||||
@@ -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 10–90, mobile
|
||||
// connector took 100, honcho 120–130 — 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,
|
||||
|
||||
@@ -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 l’assistente 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 all’amministratore di darti l’accesso.',
|
||||
},
|
||||
|
||||
fr: {
|
||||
[`${P}.title`]: 'Telegram',
|
||||
[`${P}.intro`]: 'Discutez avec l’assistant depuis Telegram en reliant votre conversation Telegram à votre compte.',
|
||||
[`${P}.status.linked`]: 'Votre conversation Telegram est reliée.',
|
||||
[`${P}.status.unlinked`]: 'Votre conversation Telegram n’est pas encore reliée.',
|
||||
[`${P}.status.chat_id`]: 'ID de conversation',
|
||||
[`${P}.howto_title`]: 'Comment la relier',
|
||||
[`${P}.howto_body`]: 'Envoyez n’importe quel message au bot — il répond avec un code à 6 caractères. Collez le code ici.',
|
||||
[`${P}.code_label`]: 'Code d’appairage',
|
||||
[`${P}.save`]: 'Relier',
|
||||
[`${P}.saved`]: 'Reliée !',
|
||||
[`${P}.relink_hint`]: 'Coller un nouveau code remplace le lien actuel.',
|
||||
[`${P}.loading`]: 'Chargement…',
|
||||
[`${P}.unavailable`]: 'Telegram n’est pas encore disponible pour vous. Demandez l’accès à votre administrateur.',
|
||||
},
|
||||
};
|
||||
@@ -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}
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user