docs: document server-side rendering pipeline, response timing middleware, and Telegram pairing API

- Add comprehensive documentation for backend content rendering in AGENTS.md, detailing the new `render_content` and `render_title` Jinja globals built on mistune with media processing, emoji shortcodes, and XSS protection
- Document the `X-Response-Time` header and bottom-left render time indicator in README.md
- Update bot token pricing documentation to clarify fallback vs gateway cost headers
- Add `email_accounts` to soft-delete tables and `idx_users_role` composite index in database schema
- Implement `telegram_pairings` and `telegram_links` table creation with column migration and indexes
- Add `/profile/{username}/telegram` endpoint to docs API with request/unpair actions
- Register `TelegramService` in main.py lifespan and add `response_timing` middleware emitting `X-Response-Time` header
- Introduce `TelegramPairForm` model and `guard_public_host_sync` synchronous host validation function
This commit is contained in:
2026-06-18 22:09:34 +00:00
parent 95dca73291
commit 6ceca3d0d4
146 changed files with 6079 additions and 392 deletions
+12 -6
View File
@@ -1531,7 +1531,7 @@ ACTIONS: tuple[Action, ...] = (
name="db_list_tables",
method="GET",
path="/dbapi/tables",
summary="List database tables exposed by the database API (admin only)",
summary="List database tables exposed by the database API (primary administrator only)",
description=(
"Returns every table reachable through the database API with its row count and "
"whether it uses soft deletes. Use this to discover what data exists before "
@@ -1539,21 +1539,23 @@ ACTIONS: tuple[Action, ...] = (
),
params=(),
requires_admin=True,
requires_primary_admin=True,
),
Action(
name="db_table_schema",
method="GET",
path="/dbapi/{table}/schema",
summary="Show a table's columns and types (admin only)",
summary="Show a table's columns and types (primary administrator only)",
description="Returns the column names, types, row count, and soft-delete flag for one table.",
params=(path("table", "Table name (from db_list_tables)."),),
requires_admin=True,
requires_primary_admin=True,
),
Action(
name="db_list_rows",
method="GET",
path="/dbapi/{table}",
summary="List rows of a table with keyset pagination (admin only)",
summary="List rows of a table with keyset pagination (primary administrator only)",
description=(
"Browses rows newest-first. Soft-deleted rows are excluded unless include_deleted is "
"true. For filtered or joined questions prefer db_query or db_design_query."
@@ -1572,12 +1574,13 @@ ACTIONS: tuple[Action, ...] = (
),
),
requires_admin=True,
requires_primary_admin=True,
),
Action(
name="db_get_row",
method="GET",
path="/dbapi/{table}/{key}/{value}",
summary="Fetch one row by a key column (admin only)",
summary="Fetch one row by a key column (primary administrator only)",
description="Returns a single row where key column equals value (key is usually 'uid').",
params=(
path("table", "Table name."),
@@ -1585,12 +1588,13 @@ ACTIONS: tuple[Action, ...] = (
path("value", "Value of the key column."),
),
requires_admin=True,
requires_primary_admin=True,
),
Action(
name="db_query",
method="POST",
path="/dbapi/query",
summary="Run a read-only SQL SELECT and return rows (admin only)",
summary="Run a read-only SQL SELECT and return rows (primary administrator only)",
description=(
"Executes a SINGLE validated SELECT statement read-only and returns the rows. Only "
"SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected. The database API is "
@@ -1603,13 +1607,14 @@ ACTIONS: tuple[Action, ...] = (
body("dialect", "Optional source SQL dialect (default sqlite)."),
),
requires_admin=True,
requires_primary_admin=True,
read_only=True,
),
Action(
name="db_design_query",
method="POST",
path="/dbapi/nl",
summary="Design a SQL SELECT from a natural-language question (admin only)",
summary="Design a SQL SELECT from a natural-language question (primary administrator only)",
description=(
"Turns a plain-language question about one table into a validated read-only SELECT. "
"It auto-adds 'deleted_at IS NULL' for soft-delete tables unless apply_soft_delete is "
@@ -1636,6 +1641,7 @@ ACTIONS: tuple[Action, ...] = (
),
),
requires_admin=True,
requires_primary_admin=True,
read_only=True,
),
Action(
@@ -55,6 +55,8 @@ CONFIRM_REQUIRED = {
"notification_reset",
"gateway_provider_delete",
"gateway_model_delete",
"email_account_delete",
"email_delete_message",
}
CONDITIONAL_CONFIRM = {
@@ -103,6 +105,14 @@ _DEVII_MECHANIC_EVENTS = {
"customize_reset": "devii.customization.reset",
"notification_set": "devii.notification.set",
"notification_reset": "devii.notification.reset",
"email_account_set": "email.account.set",
"email_account_delete": "email.account.delete",
"email_send": "email.send",
"email_delete_message": "email.message.delete",
"email_move_message": "email.message.move",
"email_mark": "email.message.flag",
"email_set_flags": "email.message.flag",
"telegram_send": "telegram.send",
}
_DEVII_CONTAINER_EVENTS = {
@@ -216,6 +226,19 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
f"such as rm, dd, truncate, or drop): {command!r}. Show the user the exact command, get "
"explicit confirmation, then call again with confirm=true."
)
if name == "email_account_delete":
label = str(arguments.get("account", "")).strip() or "(unspecified)"
return ToolInputError(
f"Deleting the saved email account '{label}' removes its stored connection settings. "
"Ask the user to confirm explicitly, then call again with confirm=true."
)
if name == "email_delete_message":
uid = str(arguments.get("uid", "")).strip() or "(unspecified)"
return ToolInputError(
f"Deleting message {uid} on the remote mailbox is permanent unless a Trash folder is "
"given. Show the user the exact message, get explicit confirmation, then call again "
"with confirm=true."
)
if name in CONFIRM_REQUIRED:
return ToolInputError(
"This removes the item as a soft delete: it disappears from every surface and is only "
@@ -236,6 +259,7 @@ class Dispatcher:
avatar: AvatarController | None = None,
browser: Any = None,
is_admin: bool = False,
is_primary_admin: bool = False,
quota_provider: Any = None,
owner_kind: str = "guest",
owner_id: str = "",
@@ -250,6 +274,7 @@ class Dispatcher:
self._avatar = avatar
self._browser = browser
self._is_admin = is_admin
self._is_primary_admin = is_primary_admin
self._owner_kind = owner_kind
self._owner_id = owner_id
self._fetch = FetchController(settings)
@@ -272,6 +297,12 @@ class Dispatcher:
from ..ai_modifier import AiModifierController
self._ai_modifier = AiModifierController(owner_kind, owner_id)
from ..email import EmailController
self._email = EmailController(settings, owner_kind, owner_id)
from ..telegram import TelegramSendController
self._telegram = TelegramSendController(owner_kind, owner_id)
self._virtual_tools = virtual_tools
self._behavior = behavior
self._read_files: set[tuple[str, str]] = set()
@@ -322,6 +353,11 @@ class Dispatcher:
"This information is restricted to administrators.",
tool=name,
)
if action.requires_primary_admin and not self._is_primary_admin:
raise AuthRequiredError(
"This tool is restricted to the primary administrator.",
tool=name,
)
guard = confirmation_error(name, arguments)
if guard is not None:
raise guard
@@ -427,6 +463,12 @@ class Dispatcher:
if action.handler == "ai_modifier":
return await self._ai_modifier.dispatch(action.name, arguments)
if action.handler == "email":
return await self._email.dispatch(action.name, arguments)
if action.handler == "telegram":
return await self._telegram.dispatch(action.name, arguments)
if action.handler == "behavior":
if self._behavior is None:
return error_result(
@@ -0,0 +1,258 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
REMOTE_NOTE = (
"This reaches the user's OWN external mailbox over IMAP/SMTP, not this platform. The "
"account must be configured first with email_account_set."
)
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
ACCOUNT = arg(
"account",
"The label of the configured email account to use (see email_accounts_list).",
required=True,
)
FOLDER = arg("folder", "Mailbox folder; defaults to INBOX when omitted.")
UID = arg("uid", "The message UID returned by email_list_messages / email_search.", required=True)
FILTERS = (
arg("unseen", "Only unread messages.", kind="boolean"),
arg("seen", "Only read messages.", kind="boolean"),
arg("flagged", "Only flagged messages.", kind="boolean"),
arg("from", "Match the sender address or name."),
arg("subject", "Match text in the subject."),
arg("since", "Only messages on or after this date, formatted DD-Mon-YYYY (e.g. 01-Jan-2026)."),
arg("text", "Match text anywhere in the message."),
)
EMAIL_ACTIONS: tuple[Action, ...] = (
Action(
name="email_accounts_list",
method="LOCAL",
path="",
summary="List the user's configured email accounts (passwords are never returned)",
description="Returns each saved account's connection settings without the password.",
handler="email",
requires_auth=True,
read_only=True,
),
Action(
name="email_account_get",
method="LOCAL",
path="",
summary="Read one configured email account's connection settings",
description="Returns the account settings without the password (password_set indicates whether one is stored).",
handler="email",
requires_auth=True,
read_only=True,
params=(ACCOUNT,),
),
Action(
name="email_account_set",
method="LOCAL",
path="",
summary="Create or update an email account connection (IMAP + SMTP)",
description=(
"Saves an IMAP/SMTP connection under a label. Sensible defaults are applied when a "
"field is omitted: imap_port 993 with imap_ssl on, smtp_port 587 with smtp_starttls on, "
"from_address falls back to username. Provide host, username and password at minimum. "
+ REMOTE_NOTE
),
handler="email",
requires_auth=True,
params=(
ACCOUNT,
arg("imap_host", "IMAP server hostname (e.g. imap.gmail.com)."),
arg("imap_port", "IMAP port (default 993).", kind="integer"),
arg("imap_ssl", "Use implicit SSL/TLS for IMAP (default true).", kind="boolean"),
arg("imap_starttls", "Upgrade a plain IMAP connection with STARTTLS (default false).", kind="boolean"),
arg("smtp_host", "SMTP server hostname (e.g. smtp.gmail.com)."),
arg("smtp_port", "SMTP port (default 587).", kind="integer"),
arg("smtp_ssl", "Use implicit SSL/TLS for SMTP (default false; set true for port 465).", kind="boolean"),
arg("smtp_starttls", "Upgrade a plain SMTP connection with STARTTLS (default true).", kind="boolean"),
arg("username", "Login username (usually the full email address)."),
arg("password", "Login password or app-specific password."),
arg("from_address", "From address for sent mail (defaults to the username)."),
arg("from_name", "Display name for sent mail."),
),
),
Action(
name="email_account_delete",
method="LOCAL",
path="",
summary="Delete a saved email account connection (confirmation required)",
description="Removes the stored connection. Does not touch the remote mailbox.",
handler="email",
requires_auth=True,
params=(
ACCOUNT,
arg(
"confirm",
"Set to true only after the user has confirmed the deletion.",
required=True,
kind="boolean",
),
),
),
Action(
name="email_list_folders",
method="LOCAL",
path="",
summary="List the mailbox folders on the account",
description=REMOTE_NOTE,
handler="email",
requires_auth=True,
read_only=True,
params=(ACCOUNT,),
),
Action(
name="email_list_messages",
method="LOCAL",
path="",
summary="List messages in a folder, newest first, with optional filters",
description=(
"Returns message summaries (uid, from, subject, date, flags). Use the filters to narrow "
"the set and limit/offset to page. " + REMOTE_NOTE
),
handler="email",
requires_auth=True,
read_only=True,
params=(
ACCOUNT,
FOLDER,
*FILTERS,
arg("limit", "Maximum messages to return (1-100, default 25).", kind="integer"),
arg("offset", "Number of messages to skip for paging (default 0).", kind="integer"),
),
),
Action(
name="email_read_message",
method="LOCAL",
path="",
summary="Read the full body, headers and attachment list of one message",
description="Fetches the decoded text/html body and attachment metadata. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
read_only=True,
params=(ACCOUNT, FOLDER, UID),
),
Action(
name="email_search",
method="LOCAL",
path="",
summary="Search a folder by sender, subject, date, text or read/flag state",
description="Returns matching message summaries. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
read_only=True,
params=(
ACCOUNT,
FOLDER,
*FILTERS,
arg("limit", "Maximum messages to return (1-100, default 25).", kind="integer"),
),
),
Action(
name="email_mark",
method="LOCAL",
path="",
summary="Mark a message read, unread, flagged or unflagged",
description="Convenience wrapper over the IMAP \\Seen and \\Flagged flags. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
params=(
ACCOUNT,
FOLDER,
UID,
arg("state", "One of: read, unread, flagged, unflagged.", required=True),
),
),
Action(
name="email_set_flags",
method="LOCAL",
path="",
summary="Add or remove arbitrary IMAP flags on a message",
description="For advanced use; prefer email_mark for read/flagged. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
params=(
ACCOUNT,
FOLDER,
UID,
arg("flags", "Comma-separated IMAP flags (e.g. \\Seen, \\Flagged, \\Answered).", required=True),
arg("add", "true to add the flags, false to remove them (default true).", kind="boolean"),
),
),
Action(
name="email_move_message",
method="LOCAL",
path="",
summary="Move a message to another folder",
description="Copies the message to the destination folder and removes it from the source. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
params=(
ACCOUNT,
FOLDER,
UID,
arg("destination", "Destination folder name.", required=True),
),
),
Action(
name="email_delete_message",
method="LOCAL",
path="",
summary="Delete a message (confirmation required)",
description=(
"Deletes the message. If 'trash' is given the message is moved there; otherwise it is "
"flagged \\Deleted and expunged. " + REMOTE_NOTE
),
handler="email",
requires_auth=True,
params=(
ACCOUNT,
FOLDER,
UID,
arg("trash", "Optional Trash folder to move the message into instead of expunging."),
arg(
"confirm",
"Set to true only after the user has confirmed the deletion.",
required=True,
kind="boolean",
),
),
),
Action(
name="email_send",
method="LOCAL",
path="",
summary="Send an email via the account's SMTP server",
description="Composes and sends a message. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
params=(
ACCOUNT,
arg("to", "Recipient address or comma-separated list.", required=True),
arg("subject", "Email subject."),
arg("body", "Plain-text body."),
arg("cc", "CC recipients (comma-separated)."),
arg("bcc", "BCC recipients (comma-separated)."),
arg("html", "Optional HTML alternative body."),
arg("in_reply_to", "Optional Message-ID this email replies to."),
),
),
)
+8 -1
View File
@@ -27,6 +27,7 @@ class Action:
params: tuple[Param, ...] = ()
requires_auth: bool = True
requires_admin: bool = False
requires_primary_admin: bool = False
handler: Literal[
"http",
"login",
@@ -46,6 +47,8 @@ class Action:
"notification",
"behavior",
"virtual_tool",
"email",
"telegram",
] = "http"
freeform_body: bool = False
ajax: bool = False
@@ -115,11 +118,15 @@ class Catalog:
return [action.tool_schema() for action in self.actions]
def tool_schemas_for(
self, authenticated: bool, is_admin: bool = False
self,
authenticated: bool,
is_admin: bool = False,
is_primary_admin: bool = False,
) -> list[dict[str, Any]]:
return [
action.tool_schema()
for action in self.actions
if (authenticated or not action.requires_auth)
and (is_admin or not action.requires_admin)
and (is_primary_admin or not action.requires_primary_admin)
]
@@ -0,0 +1,31 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
TELEGRAM_ACTIONS: tuple[Action, ...] = (
Action(
name="telegram_send",
method="LOCAL",
path="",
summary="Send a message to the user's connected Telegram chat",
description=(
"Delivers a markdown message to the signed-in user's paired Telegram account. Use this "
"to push notifications, reminders, or results to the user on Telegram, including from a "
"scheduled task. Requires the user to have paired Telegram from their profile and the "
"Telegram bot service to be running. Markdown is rendered to Telegram formatting and "
"long messages are split automatically."
),
handler="telegram",
requires_auth=True,
params=(
Param(
name="text",
location="body",
description="The markdown message to deliver to the user's Telegram chat.",
required=True,
),
),
),
)