forked from retoor/devplacepy
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:
@@ -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."),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user