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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -53,8 +53,25 @@ SYSTEM_PROMPT = (
|
||||
"highlight_element, show_toast, scroll_to_element and clear_highlights guide them with live, "
|
||||
"on-screen tutorials; run_js executes JavaScript and returns a value. The session and this "
|
||||
"conversation persist and reconnect automatically across navigation. "
|
||||
"Confirm destructive actions with the user first. Schedule autonomous work with "
|
||||
"create_task and related tools. All times are UTC.\n\n"
|
||||
"Confirm destructive actions with the user first.\n\n"
|
||||
"REMINDERS AND SCHEDULED TASKS\n"
|
||||
"When the user asks you to remind, tell, notify, ping, wake, or alert them after a delay or "
|
||||
"at a time ('remind me in 40 seconds', 'tell me to go upstairs in 2 minutes', 'every "
|
||||
"morning at 9 post the news'), DO NOT answer or perform the instruction immediately. CREATE "
|
||||
"A SCHEDULED TASK with create_task so it fires later, then confirm the schedule. A request "
|
||||
"with a delay or a time is never something to do right now - it is something to schedule. "
|
||||
"Choose the schedule precisely: a RELATIVE request ('in N seconds/minutes/hours') uses "
|
||||
"kind='once' with delay_seconds; an ABSOLUTE wall-clock time ('at 3pm', 'tomorrow 09:00') "
|
||||
"uses kind='once' with run_at in UTC, converting from the user's local timezone shown in the "
|
||||
"CURRENT TIME section; a RECURRING request ('every day', 'each Monday') uses kind='cron'. "
|
||||
"The prompt you store is what a fresh agent runs when the task fires and it has no other "
|
||||
"context, so write a complete self-contained instruction (for a reminder: 'Tell the user to "
|
||||
"go upstairs now.'). For any reminder, pass notify=true so the user is sent an in-app "
|
||||
"notification and toast carrying your message even if the Devii terminal is closed. After "
|
||||
"creating it, tell the user exactly when it will fire, in their local time. Manage existing "
|
||||
"reminders with list_tasks, get_task, update_task, run_task_now, and delete_task. Times in "
|
||||
"task records are UTC; the CURRENT TIME section gives you the current UTC time and the "
|
||||
"user's local time and timezone for conversion.\n\n"
|
||||
"DRIVING THE BROWSER (the reliable flow)\n"
|
||||
"To act on the user's screen, follow this loop instead of guessing selectors or writing run_js. "
|
||||
"(1) get_page_context to see where they are, the open modal, and visible toasts. "
|
||||
@@ -175,6 +192,15 @@ SYSTEM_PROMPT = (
|
||||
"information the platform cannot provide and the user wants it. Never use them to answer questions "
|
||||
"about this DevPlace instance, its users, posts, settings, or metrics - those have dedicated "
|
||||
"platform tools. When platform tools can serve the request, do not call rsearch.\n\n"
|
||||
"EMAIL TOOLS (email_*)\n"
|
||||
"The email_* tools connect to the user's OWN external mailbox over IMAP/SMTP, not this "
|
||||
"platform, and are only available to signed-in users. The connection must be configured first "
|
||||
"with email_account_set under a label (host, port, username, password; sensible defaults fill "
|
||||
"the rest), and every other email tool takes that label as its 'account'. Use email_list_messages "
|
||||
"/ email_search / email_read_message to read, email_mark / email_move_message to organise, and "
|
||||
"email_send to send. Sending is a real outbound action: show the user the recipients, subject and "
|
||||
"body and get their go-ahead before calling email_send. Deleting a message or removing a saved "
|
||||
"account is confirmation-gated (call again with confirm=true only after the user agrees).\n\n"
|
||||
"CUSTOMIZATION (CSS AND JAVASCRIPT)\n"
|
||||
"The user can permanently customize the LOOK (custom CSS) and BEHAVIOUR (custom JavaScript) of "
|
||||
"the site through the customize_* tools. Every customization is scoped either to the current "
|
||||
@@ -286,7 +312,7 @@ class Agent:
|
||||
{"role": "system", "content": system_prompt}
|
||||
]
|
||||
|
||||
async def respond(self, user_text: str) -> str:
|
||||
async def respond(self, user_text: Any) -> str:
|
||||
self._inject_recalled_lessons(user_text)
|
||||
self._messages.append({"role": "user", "content": user_text})
|
||||
state = AgentState()
|
||||
@@ -305,7 +331,9 @@ class Agent:
|
||||
chunk_store=self._chunk_store,
|
||||
)
|
||||
|
||||
def _inject_recalled_lessons(self, user_text: str) -> None:
|
||||
def _inject_recalled_lessons(self, user_text: Any) -> None:
|
||||
if not isinstance(user_text, str):
|
||||
return
|
||||
if self._lessons is None or self._lessons.count() == 0:
|
||||
return
|
||||
hits = self._lessons.search(user_text, k=self._settings.recall_top_k)
|
||||
|
||||
@@ -167,9 +167,17 @@ async def run(settings: Settings, prompt: Optional[str] = None) -> None:
|
||||
chunk_store = ChunkStore()
|
||||
agentic = AgenticController(lessons, settings)
|
||||
dispatcher = Dispatcher(
|
||||
CATALOG, client, settings, controller, agentic, is_admin=True
|
||||
CATALOG,
|
||||
client,
|
||||
settings,
|
||||
controller,
|
||||
agentic,
|
||||
is_admin=True,
|
||||
is_primary_admin=True,
|
||||
)
|
||||
tools = CATALOG.tool_schemas_for(
|
||||
client.authenticated, is_admin=True, is_primary_admin=True
|
||||
)
|
||||
tools = CATALOG.tool_schemas_for(client.authenticated, is_admin=True)
|
||||
agentic.bind(
|
||||
llm=llm,
|
||||
dispatcher=dispatcher,
|
||||
|
||||
@@ -38,6 +38,7 @@ DEFAULT_FETCH_TIMEOUT_SECONDS = 300.0
|
||||
DEFAULT_FETCH_MAX_BYTES = 8_000_000
|
||||
DEFAULT_RSEARCH_URL = "https://rsearch.app.molodetz.nl"
|
||||
DEFAULT_RSEARCH_TIMEOUT_SECONDS = 300.0
|
||||
DEFAULT_EMAIL_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -72,6 +73,8 @@ class Settings:
|
||||
rsearch_enabled: bool
|
||||
rsearch_url: str
|
||||
rsearch_timeout_seconds: float
|
||||
email_enabled: bool
|
||||
email_timeout_seconds: float
|
||||
daily_limit_usd: float
|
||||
|
||||
|
||||
@@ -152,6 +155,11 @@ def load_settings() -> Settings:
|
||||
rsearch_timeout_seconds=float(
|
||||
os.environ.get("DEVII_RSEARCH_TIMEOUT", DEFAULT_RSEARCH_TIMEOUT_SECONDS)
|
||||
),
|
||||
email_enabled=os.environ.get("DEVII_EMAIL_ENABLED", "1").lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
email_timeout_seconds=float(
|
||||
os.environ.get("DEVII_EMAIL_TIMEOUT", DEFAULT_EMAIL_TIMEOUT_SECONDS)
|
||||
),
|
||||
daily_limit_usd=0.0,
|
||||
)
|
||||
|
||||
@@ -176,6 +184,8 @@ FIELD_FETCH_TIMEOUT = "devii_fetch_timeout"
|
||||
FIELD_RSEARCH_ENABLED = "devii_rsearch_enabled"
|
||||
FIELD_RSEARCH_URL = "devii_rsearch_url"
|
||||
FIELD_RSEARCH_TIMEOUT = "devii_rsearch_timeout"
|
||||
FIELD_EMAIL_ENABLED = "devii_email_enabled"
|
||||
FIELD_EMAIL_TIMEOUT = "devii_email_timeout"
|
||||
|
||||
LESSONS_DB_PATH = os.environ.get("DEVII_LESSONS_DB", str(DEVII_LESSONS_DB))
|
||||
|
||||
@@ -234,5 +244,9 @@ def build_settings(
|
||||
rsearch_timeout_seconds=float(
|
||||
config.get(FIELD_RSEARCH_TIMEOUT) or DEFAULT_RSEARCH_TIMEOUT_SECONDS
|
||||
),
|
||||
email_enabled=bool(config.get(FIELD_EMAIL_ENABLED, True)),
|
||||
email_timeout_seconds=float(
|
||||
config.get(FIELD_EMAIL_TIMEOUT) or DEFAULT_EMAIL_TIMEOUT_SECONDS
|
||||
),
|
||||
daily_limit_usd=effective_daily_limit(config, owner_kind, is_admin),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .controller import CostController
|
||||
from .tracker import CostTracker, Pricing, record_usage, reset_tracker, set_tracker
|
||||
from .tracker import (
|
||||
CostTracker,
|
||||
Pricing,
|
||||
record_cost,
|
||||
record_usage,
|
||||
reset_tracker,
|
||||
set_tracker,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CostTracker",
|
||||
"Pricing",
|
||||
"CostController",
|
||||
"record_usage",
|
||||
"record_cost",
|
||||
"set_tracker",
|
||||
"reset_tracker",
|
||||
]
|
||||
|
||||
@@ -48,6 +48,12 @@ class CostTracker:
|
||||
cache_hit_tokens: int = 0
|
||||
cache_miss_tokens: int = 0
|
||||
reasoning_tokens: int = 0
|
||||
native_cost_usd: float = 0.0
|
||||
has_native: bool = False
|
||||
|
||||
def record_native(self, cost_usd: float) -> None:
|
||||
self.native_cost_usd += float(cost_usd or 0.0)
|
||||
self.has_native = True
|
||||
|
||||
def record(self, usage: Optional[dict[str, Any]]) -> None:
|
||||
if not usage:
|
||||
@@ -91,7 +97,11 @@ class CostTracker:
|
||||
self.cache_miss_tokens / PER_MILLION * self.pricing.cache_miss_per_m
|
||||
)
|
||||
output = self.completion_tokens / PER_MILLION * self.pricing.output_per_m
|
||||
total = cache_hit + cache_miss + output
|
||||
total = (
|
||||
self.native_cost_usd
|
||||
if self.has_native
|
||||
else (cache_hit + cache_miss + output)
|
||||
)
|
||||
return {
|
||||
"cache_hit_input": round(cache_hit, 8),
|
||||
"cache_miss_input": round(cache_miss, 8),
|
||||
@@ -163,3 +173,9 @@ def record_usage(usage: Optional[dict[str, Any]]) -> None:
|
||||
tracker = _active_tracker.get()
|
||||
if tracker is not None:
|
||||
tracker.record(usage)
|
||||
|
||||
|
||||
def record_cost(cost_usd: float) -> None:
|
||||
tracker = _active_tracker.get()
|
||||
if tracker is not None:
|
||||
tracker.record_native(cost_usd)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .controller import EmailController
|
||||
|
||||
__all__ = ["EmailController"]
|
||||
@@ -0,0 +1,360 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy import database
|
||||
from devplacepy.services.email import EmailAccount, EmailClient, EmailError
|
||||
from devplacepy.services.email.client import imap_quote
|
||||
|
||||
from ..config import Settings
|
||||
from ..errors import AuthRequiredError, ToolInputError, UpstreamError
|
||||
|
||||
logger = logging.getLogger("devii.email")
|
||||
|
||||
ACCOUNT_FIELDS = (
|
||||
"imap_host",
|
||||
"imap_port",
|
||||
"imap_ssl",
|
||||
"imap_starttls",
|
||||
"smtp_host",
|
||||
"smtp_port",
|
||||
"smtp_ssl",
|
||||
"smtp_starttls",
|
||||
"username",
|
||||
"password",
|
||||
"from_address",
|
||||
"from_name",
|
||||
)
|
||||
BOOLEAN_FIELDS = ("imap_ssl", "imap_starttls", "smtp_ssl", "smtp_starttls")
|
||||
INTEGER_FIELDS = ("imap_port", "smtp_port")
|
||||
MARK_FLAGS = {
|
||||
"read": ("\\Seen", True),
|
||||
"unread": ("\\Seen", False),
|
||||
"flagged": ("\\Flagged", True),
|
||||
"unflagged": ("\\Flagged", False),
|
||||
}
|
||||
|
||||
|
||||
def _flag(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _text(arguments: dict[str, Any], key: str) -> str:
|
||||
return str(arguments.get(key, "") or "").strip()
|
||||
|
||||
|
||||
def _address_list(value: Any) -> list[str]:
|
||||
if isinstance(value, (list, tuple)):
|
||||
items = [str(item).strip() for item in value]
|
||||
else:
|
||||
items = [chunk.strip() for chunk in str(value or "").split(",")]
|
||||
return [item for item in items if item]
|
||||
|
||||
|
||||
class EmailController:
|
||||
def __init__(
|
||||
self, settings: Settings, owner_kind: str = "guest", owner_id: str = ""
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if not getattr(self._settings, "email_enabled", True):
|
||||
raise ToolInputError(
|
||||
"Email tools are disabled by the administrator."
|
||||
)
|
||||
if self._owner_kind != "user":
|
||||
raise AuthRequiredError(
|
||||
"Email tools are only available to signed-in users.", tool=name
|
||||
)
|
||||
if name == "email_accounts_list":
|
||||
return self._accounts_list()
|
||||
if name == "email_account_get":
|
||||
return self._account_get(arguments)
|
||||
if name == "email_account_set":
|
||||
return self._account_set(arguments)
|
||||
if name == "email_account_delete":
|
||||
return self._account_delete(arguments)
|
||||
if name == "email_list_folders":
|
||||
return await self._list_folders(arguments)
|
||||
if name == "email_list_messages":
|
||||
return await self._list_messages(arguments)
|
||||
if name == "email_read_message":
|
||||
return await self._read_message(arguments)
|
||||
if name == "email_search":
|
||||
return await self._search(arguments)
|
||||
if name == "email_set_flags":
|
||||
return await self._set_flags(arguments)
|
||||
if name == "email_mark":
|
||||
return await self._mark(arguments)
|
||||
if name == "email_move_message":
|
||||
return await self._move(arguments)
|
||||
if name == "email_delete_message":
|
||||
return await self._delete(arguments)
|
||||
if name == "email_send":
|
||||
return await self._send(arguments)
|
||||
raise ToolInputError(f"Unknown email tool: {name}")
|
||||
|
||||
def _label(self, arguments: dict[str, Any]) -> str:
|
||||
label = _text(arguments, "account")
|
||||
if not label:
|
||||
raise ToolInputError("An 'account' label is required.")
|
||||
return label
|
||||
|
||||
def _client(self, arguments: dict[str, Any]) -> EmailClient:
|
||||
label = self._label(arguments)
|
||||
row = database.get_email_account(self._owner_kind, self._owner_id, label)
|
||||
if not row:
|
||||
raise ToolInputError(
|
||||
f"No email account named '{label}'. Add it with email_account_set first."
|
||||
)
|
||||
return EmailClient(
|
||||
EmailAccount.from_row(row),
|
||||
timeout=getattr(self._settings, "email_timeout_seconds", 30.0),
|
||||
)
|
||||
|
||||
def _public_account(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"label": row.get("label"),
|
||||
"imap_host": row.get("imap_host"),
|
||||
"imap_port": row.get("imap_port"),
|
||||
"imap_ssl": bool(row.get("imap_ssl")),
|
||||
"imap_starttls": bool(row.get("imap_starttls")),
|
||||
"smtp_host": row.get("smtp_host"),
|
||||
"smtp_port": row.get("smtp_port"),
|
||||
"smtp_ssl": bool(row.get("smtp_ssl")),
|
||||
"smtp_starttls": bool(row.get("smtp_starttls")),
|
||||
"username": row.get("username"),
|
||||
"password_set": bool(row.get("password")),
|
||||
"from_address": row.get("from_address"),
|
||||
"from_name": row.get("from_name"),
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
|
||||
def _accounts_list(self) -> str:
|
||||
rows = database.list_email_accounts(self._owner_kind, self._owner_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"accounts": [self._public_account(row) for row in rows],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _account_get(self, arguments: dict[str, Any]) -> str:
|
||||
label = self._label(arguments)
|
||||
row = database.get_email_account(self._owner_kind, self._owner_id, label)
|
||||
if not row:
|
||||
raise ToolInputError(f"No email account named '{label}'.")
|
||||
return json.dumps(
|
||||
{"status": "success", "account": self._public_account(row)},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _account_set(self, arguments: dict[str, Any]) -> str:
|
||||
label = self._label(arguments)
|
||||
fields: dict[str, Any] = {}
|
||||
for key in ACCOUNT_FIELDS:
|
||||
if key not in arguments or arguments[key] is None:
|
||||
continue
|
||||
value = arguments[key]
|
||||
if key in BOOLEAN_FIELDS:
|
||||
fields[key] = 1 if _flag(value) else 0
|
||||
elif key in INTEGER_FIELDS:
|
||||
try:
|
||||
fields[key] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ToolInputError(f"'{key}' must be an integer port number.")
|
||||
else:
|
||||
fields[key] = str(value).strip()
|
||||
row = database.set_email_account(
|
||||
self._owner_kind, self._owner_id, label, fields
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"saved": True,
|
||||
"account": self._public_account(row),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _account_delete(self, arguments: dict[str, Any]) -> str:
|
||||
label = self._label(arguments)
|
||||
removed = database.delete_email_account(
|
||||
self._owner_kind,
|
||||
self._owner_id,
|
||||
label,
|
||||
deleted_by=f"user:{self._owner_id}",
|
||||
)
|
||||
return json.dumps(
|
||||
{"status": "success", "removed": int(removed), "account": label},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _folder(self, arguments: dict[str, Any]) -> str:
|
||||
return _text(arguments, "folder") or "INBOX"
|
||||
|
||||
def _limit(self, arguments: dict[str, Any], default: int = 25) -> int:
|
||||
try:
|
||||
limit = int(arguments.get("limit", default))
|
||||
except (TypeError, ValueError):
|
||||
limit = default
|
||||
return max(1, min(limit, 100))
|
||||
|
||||
def _offset(self, arguments: dict[str, Any]) -> int:
|
||||
try:
|
||||
offset = int(arguments.get("offset", 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
return max(0, offset)
|
||||
|
||||
def _criteria(self, arguments: dict[str, Any]) -> list[str]:
|
||||
criteria: list[str] = []
|
||||
if _flag(arguments.get("unseen")):
|
||||
criteria.append("UNSEEN")
|
||||
if _flag(arguments.get("seen")):
|
||||
criteria.append("SEEN")
|
||||
if _flag(arguments.get("flagged")):
|
||||
criteria.append("FLAGGED")
|
||||
sender = _text(arguments, "from")
|
||||
if sender:
|
||||
criteria += ["FROM", imap_quote(sender)]
|
||||
subject = _text(arguments, "subject")
|
||||
if subject:
|
||||
criteria += ["SUBJECT", imap_quote(subject)]
|
||||
since = _text(arguments, "since")
|
||||
if since:
|
||||
criteria += ["SINCE", since]
|
||||
text = _text(arguments, "text")
|
||||
if text:
|
||||
criteria += ["TEXT", imap_quote(text)]
|
||||
return criteria
|
||||
|
||||
async def _run(self, func: Any, *args: Any) -> Any:
|
||||
try:
|
||||
return await asyncio.to_thread(func, *args)
|
||||
except EmailError as exc:
|
||||
if exc.kind in ("config", "not_found", "blocked"):
|
||||
raise ToolInputError(exc.message) from exc
|
||||
raise UpstreamError(exc.message) from exc
|
||||
|
||||
def _ok(self, payload: dict[str, Any]) -> str:
|
||||
return json.dumps({"status": "success", **payload}, ensure_ascii=False)
|
||||
|
||||
async def _list_folders(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
folders = await self._run(client.list_folders)
|
||||
return self._ok({"folders": folders})
|
||||
|
||||
async def _list_messages(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
result = await self._run(
|
||||
client.list_messages,
|
||||
self._folder(arguments),
|
||||
self._criteria(arguments),
|
||||
self._limit(arguments),
|
||||
self._offset(arguments),
|
||||
)
|
||||
return self._ok(result)
|
||||
|
||||
async def _read_message(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
uid = _text(arguments, "uid")
|
||||
if not uid:
|
||||
raise ToolInputError("A message 'uid' is required.")
|
||||
result = await self._run(
|
||||
client.read_message, self._folder(arguments), uid
|
||||
)
|
||||
return self._ok({"message": result})
|
||||
|
||||
async def _search(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
result = await self._run(
|
||||
client.search,
|
||||
self._folder(arguments),
|
||||
self._criteria(arguments),
|
||||
self._limit(arguments),
|
||||
)
|
||||
return self._ok(result)
|
||||
|
||||
async def _set_flags(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
uid = _text(arguments, "uid")
|
||||
if not uid:
|
||||
raise ToolInputError("A message 'uid' is required.")
|
||||
flags = _address_list(arguments.get("flags"))
|
||||
if not flags:
|
||||
raise ToolInputError("At least one flag is required (e.g. \\Seen).")
|
||||
add = _flag(arguments.get("add", True)) if "add" in arguments else True
|
||||
result = await self._run(
|
||||
client.set_flags, self._folder(arguments), uid, flags, add
|
||||
)
|
||||
return self._ok(result)
|
||||
|
||||
async def _mark(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
uid = _text(arguments, "uid")
|
||||
if not uid:
|
||||
raise ToolInputError("A message 'uid' is required.")
|
||||
state = _text(arguments, "state").lower()
|
||||
if state not in MARK_FLAGS:
|
||||
raise ToolInputError(
|
||||
"'state' must be one of: read, unread, flagged, unflagged."
|
||||
)
|
||||
flag, add = MARK_FLAGS[state]
|
||||
result = await self._run(
|
||||
client.set_flags, self._folder(arguments), uid, [flag], add
|
||||
)
|
||||
return self._ok({**result, "state": state})
|
||||
|
||||
async def _move(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
uid = _text(arguments, "uid")
|
||||
destination = _text(arguments, "destination")
|
||||
if not uid or not destination:
|
||||
raise ToolInputError("Both 'uid' and 'destination' are required.")
|
||||
result = await self._run(
|
||||
client.move_message, self._folder(arguments), uid, destination
|
||||
)
|
||||
return self._ok(result)
|
||||
|
||||
async def _delete(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
uid = _text(arguments, "uid")
|
||||
if not uid:
|
||||
raise ToolInputError("A message 'uid' is required.")
|
||||
trash = _text(arguments, "trash") or None
|
||||
result = await self._run(
|
||||
client.delete_message, self._folder(arguments), uid, trash
|
||||
)
|
||||
return self._ok(result)
|
||||
|
||||
async def _send(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
to = _address_list(arguments.get("to"))
|
||||
if not to:
|
||||
raise ToolInputError("At least one 'to' recipient is required.")
|
||||
subject = _text(arguments, "subject")
|
||||
body = str(arguments.get("body", "") or "")
|
||||
if not subject and not body:
|
||||
raise ToolInputError("A 'subject' or 'body' is required.")
|
||||
result = await self._run(
|
||||
client.send_message,
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
_address_list(arguments.get("cc")),
|
||||
_address_list(arguments.get("bcc")),
|
||||
str(arguments.get("html", "") or "") or None,
|
||||
_text(arguments, "in_reply_to") or None,
|
||||
)
|
||||
return self._ok(result)
|
||||
@@ -44,6 +44,7 @@ class DeviiHub:
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
is_admin: bool = False,
|
||||
is_primary_admin: bool = False,
|
||||
channel: str = "main",
|
||||
) -> DeviiSession:
|
||||
key = (owner_kind, owner_id, channel)
|
||||
@@ -57,7 +58,11 @@ class DeviiHub:
|
||||
# ephemeral stores so Devii's tasks, lessons, behavior and virtual tools never leak into it
|
||||
# (and a Docii reflection never pollutes the user's Devii memory). Only the conversation
|
||||
# thread persists, keyed per channel in the shared ConversationStore.
|
||||
owned_db = db if (owner_kind == "user" and channel == "main") else memory_db()
|
||||
owned_db = (
|
||||
db
|
||||
if (owner_kind == "user" and channel in ("main", "telegram"))
|
||||
else memory_db()
|
||||
)
|
||||
task_store = TaskStore(owned_db, owner_kind, owner_id)
|
||||
lessons = LessonStore(owned_db, owner_kind, owner_id)
|
||||
virtual_tool_store = VirtualToolStore(owned_db, owner_kind, owner_id)
|
||||
@@ -75,6 +80,7 @@ class DeviiHub:
|
||||
behavior_store,
|
||||
self._stores,
|
||||
is_admin=is_admin,
|
||||
is_primary_admin=is_primary_admin,
|
||||
channel=channel,
|
||||
)
|
||||
if owner_kind == "user":
|
||||
@@ -105,7 +111,7 @@ class DeviiHub:
|
||||
async def gc_idle(self) -> int:
|
||||
removed = 0
|
||||
for key, session in list(self._sessions.items()):
|
||||
if session.connection_count == 0:
|
||||
if session.connection_count == 0 and not session.has_pending_tasks():
|
||||
await session.aclose()
|
||||
del self._sessions[key]
|
||||
removed += 1
|
||||
|
||||
@@ -9,7 +9,7 @@ import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from .config import Settings
|
||||
from .cost import record_usage
|
||||
from .cost import record_cost, record_usage
|
||||
from .errors import LLMError
|
||||
|
||||
logger = logging.getLogger("devii.llm")
|
||||
@@ -67,6 +67,7 @@ class LLMClient:
|
||||
raise LLMError("Model response contained no message.", body=str(data)[:500])
|
||||
|
||||
record_usage(data.get("usage"))
|
||||
self._record_native_cost(response)
|
||||
logger.debug(
|
||||
"LLM response received (tool_calls=%s)", bool(message.get("tool_calls"))
|
||||
)
|
||||
@@ -94,6 +95,7 @@ class LLMClient:
|
||||
except (ValueError, KeyError, IndexError) as exc:
|
||||
raise LLMError("Model endpoint returned an unexpected response.") from exc
|
||||
record_usage(data.get("usage"))
|
||||
self._record_native_cost(response)
|
||||
return content
|
||||
|
||||
async def summarize(self, text: str) -> str:
|
||||
@@ -124,8 +126,19 @@ class LLMClient:
|
||||
"Model summarization returned an unexpected response."
|
||||
) from exc
|
||||
record_usage(data.get("usage"))
|
||||
self._record_native_cost(response)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _record_native_cost(response: httpx.Response) -> None:
|
||||
raw = response.headers.get("X-Gateway-Cost-USD")
|
||||
if raw is None:
|
||||
return
|
||||
try:
|
||||
record_cost(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _reason(response: httpx.Response) -> str:
|
||||
try:
|
||||
|
||||
@@ -13,10 +13,12 @@ from .actions.container_actions import CONTAINER_ACTIONS
|
||||
from .actions.cost_actions import COST_ACTIONS
|
||||
from .actions.customization_actions import CUSTOMIZATION_ACTIONS
|
||||
from .actions.docs_actions import DOCS_ACTIONS
|
||||
from .actions.email_actions import EMAIL_ACTIONS
|
||||
from .actions.fetch_actions import FETCH_ACTIONS
|
||||
from .actions.notification_actions import NOTIFICATION_ACTIONS
|
||||
from .actions.rsearch_actions import RSEARCH_ACTIONS
|
||||
from .actions.spec import Catalog
|
||||
from .actions.telegram_actions import TELEGRAM_ACTIONS
|
||||
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
|
||||
from .agentic.actions import AGENTIC_ACTIONS
|
||||
from .tasks.actions import TASK_ACTIONS
|
||||
@@ -38,6 +40,8 @@ CATALOG = Catalog(
|
||||
+ NOTIFICATION_ACTIONS
|
||||
+ AI_CORRECTION_ACTIONS
|
||||
+ AI_MODIFIER_ACTIONS
|
||||
+ EMAIL_ACTIONS
|
||||
+ TELEGRAM_ACTIONS
|
||||
+ VIRTUAL_TOOL_ACTIONS
|
||||
)
|
||||
|
||||
|
||||
@@ -216,6 +216,25 @@ class DeviiService(BaseService):
|
||||
"minutes, so this is generous by default. Minimum five minutes.",
|
||||
group="Web search",
|
||||
),
|
||||
ConfigField(
|
||||
config.FIELD_EMAIL_ENABLED,
|
||||
"Enable email tools",
|
||||
type="bool",
|
||||
default=True,
|
||||
help="Allow the email_* tools (configure accounts, list/read/search/flag/move/delete "
|
||||
"messages, send mail) for signed-in users. These connect to the user's own external "
|
||||
"mailbox over IMAP/SMTP, not this platform. When off, those tools are refused.",
|
||||
group="Email",
|
||||
),
|
||||
ConfigField(
|
||||
config.FIELD_EMAIL_TIMEOUT,
|
||||
"Email timeout (seconds)",
|
||||
type="float",
|
||||
default=config.DEFAULT_EMAIL_TIMEOUT_SECONDS,
|
||||
minimum=1,
|
||||
help="Connection/read timeout for IMAP and SMTP calls.",
|
||||
group="Email",
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
@@ -275,6 +294,12 @@ class DeviiService(BaseService):
|
||||
def spent_24h(self, owner_kind: str, owner_id: str) -> float:
|
||||
return self.hub().ledger.spent_24h(owner_kind, owner_id)
|
||||
|
||||
def quota_exceeded(
|
||||
self, owner_kind: str, owner_id: str, is_admin: bool = False
|
||||
) -> bool:
|
||||
limit = self.daily_limit_for(owner_kind, is_admin)
|
||||
return limit > 0 and self.spent_24h(owner_kind, owner_id) >= limit
|
||||
|
||||
def reset_quota(self, owner_kind: str, owner_id: str) -> int:
|
||||
return self.hub().ledger.reset(owner_kind, owner_id)
|
||||
|
||||
@@ -288,13 +313,52 @@ class DeviiService(BaseService):
|
||||
if not self.is_enabled():
|
||||
return
|
||||
hub = self.hub()
|
||||
ensured = self._ensure_task_schedulers()
|
||||
pruned = hub.ledger.prune(48)
|
||||
removed = await hub.gc_idle()
|
||||
if pruned or removed:
|
||||
if pruned or removed or ensured:
|
||||
self.log(
|
||||
f"Housekeeping: pruned {pruned} ledger rows, closed {removed} idle sessions"
|
||||
f"Housekeeping: ensured {ensured} task scheduler(s), pruned {pruned} "
|
||||
f"ledger rows, closed {removed} idle sessions"
|
||||
)
|
||||
|
||||
def _ensure_task_schedulers(self) -> int:
|
||||
from devplacepy.database import db
|
||||
from devplacepy.utils import is_admin, is_primary_admin
|
||||
|
||||
from .tasks.store import pending_owner_ids
|
||||
|
||||
try:
|
||||
owner_ids = pending_owner_ids(db)
|
||||
except Exception: # noqa: BLE001 - a bad read must not stop housekeeping
|
||||
logger.exception("Failed to scan for owners with pending Devii tasks")
|
||||
return 0
|
||||
if not owner_ids:
|
||||
return 0
|
||||
users = db["users"] if "users" in db.tables else None
|
||||
if users is None:
|
||||
return 0
|
||||
ensured = 0
|
||||
base_url = self.instance_base_url()
|
||||
for owner_id in owner_ids:
|
||||
user = users.find_one(uid=owner_id)
|
||||
if not user:
|
||||
continue
|
||||
session = self.hub().get_or_create(
|
||||
"user",
|
||||
owner_id,
|
||||
user.get("username", ""),
|
||||
user.get("api_key", ""),
|
||||
base_url,
|
||||
is_admin=is_admin(user),
|
||||
is_primary_admin=is_primary_admin(user),
|
||||
channel="main",
|
||||
)
|
||||
session.set_timezone(user.get("timezone") or "")
|
||||
session.ensure_scheduler_started()
|
||||
ensured += 1
|
||||
return ensured
|
||||
|
||||
async def on_disable(self) -> None:
|
||||
if self._hub is not None:
|
||||
await self._hub.aclose()
|
||||
|
||||
@@ -85,6 +85,7 @@ class DeviiSession:
|
||||
behavior_store: Any,
|
||||
stores: dict[str, Any],
|
||||
is_admin: bool = False,
|
||||
is_primary_admin: bool = False,
|
||||
channel: str = "main",
|
||||
) -> None:
|
||||
self.owner_kind = owner_kind
|
||||
@@ -93,7 +94,10 @@ class DeviiSession:
|
||||
self.username = username
|
||||
self.settings = settings
|
||||
self.is_admin = is_admin
|
||||
self.is_primary_admin = is_primary_admin
|
||||
self.persist_conversation = owner_kind == "user"
|
||||
self._timezone: str = ""
|
||||
self._tz_offset_minutes: int | None = None
|
||||
self._llm = llm
|
||||
self._lessons = lessons
|
||||
self.client = PlatformClient(
|
||||
@@ -123,6 +127,7 @@ class DeviiSession:
|
||||
avatar=self.avatar,
|
||||
browser=self.browser,
|
||||
is_admin=is_admin,
|
||||
is_primary_admin=is_primary_admin,
|
||||
quota_provider=self._quota_snapshot,
|
||||
owner_kind=owner_kind,
|
||||
owner_id=owner_id,
|
||||
@@ -201,6 +206,12 @@ class DeviiSession:
|
||||
def _make_executor(self) -> Any:
|
||||
async def execute(prompt: str) -> str:
|
||||
async with self._lock:
|
||||
turn_id = uuid_utils.uuid7().hex
|
||||
started_at = _now_iso()
|
||||
self._turn_tool_calls = 0
|
||||
before = self._cost_snapshot()
|
||||
reply = ""
|
||||
error = ""
|
||||
worker = Agent(
|
||||
self.settings,
|
||||
self._llm,
|
||||
@@ -212,7 +223,16 @@ class DeviiSession:
|
||||
chunk_store=self.chunks,
|
||||
system_prompt=self._compose_system_prompt(),
|
||||
)
|
||||
return await worker.respond(prompt)
|
||||
try:
|
||||
reply = await worker.respond(prompt)
|
||||
return reply
|
||||
except Exception as exc: # noqa: BLE001 - recorded, then re-raised to the scheduler
|
||||
error = str(exc)
|
||||
raise
|
||||
finally:
|
||||
self._record_spend(
|
||||
turn_id, started_at, prompt, reply, error, before
|
||||
)
|
||||
|
||||
return execute
|
||||
|
||||
@@ -228,9 +248,7 @@ class DeviiSession:
|
||||
self._disconnected.clear()
|
||||
self.avatar.bind(self._avatar_request)
|
||||
self.browser.bind(self._client_request)
|
||||
if not self._started and self.channel == "main":
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
self.ensure_scheduler_started()
|
||||
if self._buffer:
|
||||
pending = self._buffer
|
||||
self._buffer = []
|
||||
@@ -257,6 +275,40 @@ class DeviiSession:
|
||||
len(self._conns),
|
||||
)
|
||||
|
||||
def ensure_scheduler_started(self) -> None:
|
||||
if not self._started and self.channel == "main":
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
|
||||
def has_pending_tasks(self) -> bool:
|
||||
if self.channel != "main":
|
||||
return False
|
||||
try:
|
||||
return self.store.has_pending()
|
||||
except Exception: # noqa: BLE001 - never block GC on a store read
|
||||
return False
|
||||
|
||||
def set_clientinfo(self, timezone_name: str, offset_minutes: int | None) -> None:
|
||||
if timezone_name:
|
||||
self._timezone = timezone_name
|
||||
if offset_minutes is not None:
|
||||
self._tz_offset_minutes = int(offset_minutes)
|
||||
if self.owner_kind == "user" and timezone_name:
|
||||
from devplacepy.database import set_user_timezone
|
||||
|
||||
try:
|
||||
set_user_timezone(self.owner_id, timezone_name)
|
||||
except Exception: # noqa: BLE001 - persistence must not break the socket
|
||||
logger.exception(
|
||||
"Failed to persist timezone for %s/%s",
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
)
|
||||
|
||||
def set_timezone(self, timezone_name: str) -> None:
|
||||
if timezone_name:
|
||||
self._timezone = timezone_name
|
||||
|
||||
def set_visibility(self, ws: Any, visible: bool, focused: bool) -> None:
|
||||
meta = self._conn_meta.get(ws)
|
||||
if meta is not None:
|
||||
@@ -322,9 +374,9 @@ class DeviiSession:
|
||||
)
|
||||
await self._emit({"type": "clear"}, buffer=False)
|
||||
|
||||
def spawn_turn(self, text: str) -> None:
|
||||
def spawn_turn(self, content: Any, audit_text: str | None = None) -> None:
|
||||
epoch = self._turn_epoch
|
||||
task = asyncio.create_task(self._run_turn(text, epoch))
|
||||
task = asyncio.create_task(self._run_turn(content, epoch, audit_text))
|
||||
self._turns.add(task)
|
||||
task.add_done_callback(self._turns.discard)
|
||||
|
||||
@@ -373,7 +425,9 @@ class DeviiSession:
|
||||
)
|
||||
await self._emit({"type": "status", "text": "Stopped."}, buffer=False)
|
||||
|
||||
async def _run_turn(self, text: str, epoch: int) -> None:
|
||||
async def _run_turn(
|
||||
self, content: Any, epoch: int, audit_text: str | None = None
|
||||
) -> None:
|
||||
turn_id = uuid_utils.uuid7().hex
|
||||
started_at = _now_iso()
|
||||
before = self._cost_snapshot()
|
||||
@@ -381,13 +435,19 @@ class DeviiSession:
|
||||
reply = ""
|
||||
error = ""
|
||||
cancelled = False
|
||||
prompt_text = audit_text if audit_text is not None else (
|
||||
content if isinstance(content, str) else "[image]"
|
||||
)
|
||||
try:
|
||||
async with self._lock:
|
||||
self._refresh_tools()
|
||||
self._refresh_system_prompt()
|
||||
if self.channel == "docs":
|
||||
await self._docs_topic_gate(text)
|
||||
reply = await self.agent.respond(text)
|
||||
await self._docs_topic_gate(prompt_text)
|
||||
before = self._cost_snapshot()
|
||||
reply = await self.agent.respond(content)
|
||||
if not isinstance(content, str):
|
||||
self._redact_last_user_message(prompt_text)
|
||||
if epoch == self._turn_epoch:
|
||||
await self._emit({"type": "reply", "text": reply}, buffer=True)
|
||||
except asyncio.CancelledError:
|
||||
@@ -399,15 +459,18 @@ class DeviiSession:
|
||||
if epoch == self._turn_epoch:
|
||||
await self._emit({"type": "error", "text": error}, buffer=True)
|
||||
finally:
|
||||
self._record_spend(turn_id, started_at, prompt_text, reply, error, before)
|
||||
if not cancelled and epoch == self._turn_epoch:
|
||||
self._record_turn(turn_id, started_at, text, reply, error, before)
|
||||
self._persist_history()
|
||||
if self.owner_kind == "user" and self.channel != "docs" and not error:
|
||||
from devplacepy.utils import track_action
|
||||
|
||||
track_action(self.owner_id, "devii")
|
||||
|
||||
def _builtin_tools(self) -> list[dict[str, Any]]:
|
||||
schemas = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
|
||||
schemas = CATALOG.tool_schemas_for(
|
||||
self.client.authenticated, self.is_admin, self.is_primary_admin
|
||||
)
|
||||
if self.channel == "docs":
|
||||
return [
|
||||
s
|
||||
@@ -428,7 +491,55 @@ class DeviiSession:
|
||||
return self._system_prompt
|
||||
body = self._behavior_store.text().strip()
|
||||
section = BEHAVIOR_HEADER if not body else f"{BEHAVIOR_HEADER}\n{body}"
|
||||
return f"{self._system_prompt}\n\n{section}"
|
||||
return f"{self._system_prompt}\n\n{self._clock_line()}\n\n{section}"
|
||||
|
||||
def _clock_line(self) -> str:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
tz_name = self._timezone or self._stored_timezone()
|
||||
local = ""
|
||||
if tz_name:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
local_now = now.astimezone(ZoneInfo(tz_name))
|
||||
offset = local_now.utcoffset()
|
||||
offset_text = _format_offset(offset)
|
||||
local = (
|
||||
f" The user's local time is {local_now.strftime('%Y-%m-%d %H:%M:%S')} "
|
||||
f"({tz_name}, UTC{offset_text})."
|
||||
)
|
||||
except Exception: # noqa: BLE001 - unknown tz name: fall back to UTC only
|
||||
local = ""
|
||||
if not local and self._tz_offset_minutes is not None:
|
||||
offset = timedelta(minutes=self._tz_offset_minutes)
|
||||
local = (
|
||||
f" The user's local time is "
|
||||
f"{(now + offset).strftime('%Y-%m-%d %H:%M:%S')} "
|
||||
f"(UTC{_format_offset(offset)})."
|
||||
)
|
||||
return (
|
||||
f"# CURRENT TIME\n"
|
||||
f"The current UTC time is {now.strftime('%Y-%m-%dT%H:%M:%S')}Z.{local} "
|
||||
"When the user gives a wall-clock time (for example '3pm' or 'tomorrow at 09:00'), "
|
||||
"interpret it in the user's local timezone and convert it to UTC for the run_at field. "
|
||||
"For a relative request (for example 'in 40 seconds' or 'in 2 hours'), use delay_seconds "
|
||||
"instead and do not compute an absolute time."
|
||||
)
|
||||
|
||||
def _stored_timezone(self) -> str:
|
||||
if self.owner_kind != "user":
|
||||
return ""
|
||||
try:
|
||||
from devplacepy.database import db
|
||||
|
||||
if "users" not in db.tables:
|
||||
return ""
|
||||
row = db["users"].find_one(uid=self.owner_id)
|
||||
return (row or {}).get("timezone") or ""
|
||||
except Exception: # noqa: BLE001 - clock line is best-effort
|
||||
return ""
|
||||
|
||||
def _refresh_system_prompt(self) -> None:
|
||||
messages = self.agent._messages
|
||||
@@ -506,7 +617,31 @@ class DeviiSession:
|
||||
"cost_usd": self.cost.cost_usd()["total"],
|
||||
}
|
||||
|
||||
def _record_turn(self, turn_id, started_at, prompt, reply, error, before) -> None:
|
||||
def _persist_history(self) -> None:
|
||||
if not self.persist_conversation:
|
||||
return
|
||||
try:
|
||||
self._conv.save(
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
self.agent._messages,
|
||||
self.channel,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - persistence must never break a turn
|
||||
logger.exception(
|
||||
"Failed to persist conversation for %s/%s",
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
)
|
||||
|
||||
def _redact_last_user_message(self, text: str) -> None:
|
||||
messages = self.agent._messages
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "user":
|
||||
message["content"] = text
|
||||
return
|
||||
|
||||
def _record_spend(self, turn_id, started_at, prompt, reply, error, before) -> None:
|
||||
after = self._cost_snapshot()
|
||||
usage = {
|
||||
"prompt_tokens": after["prompt_tokens"] - before["prompt_tokens"],
|
||||
@@ -517,14 +652,13 @@ class DeviiSession:
|
||||
- before["cache_miss_tokens"],
|
||||
}
|
||||
cost_delta = round(after["cost_usd"] - before["cost_usd"], 8)
|
||||
if (
|
||||
cost_delta <= 0
|
||||
and usage["prompt_tokens"] <= 0
|
||||
and usage["completion_tokens"] <= 0
|
||||
):
|
||||
return
|
||||
try:
|
||||
if self.persist_conversation:
|
||||
self._conv.save(
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
self.agent._messages,
|
||||
self.channel,
|
||||
)
|
||||
self._ledger.record(
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
@@ -690,6 +824,31 @@ class DeviiSession:
|
||||
"payload": payload[:RESULT_NOTICE_CHARS],
|
||||
}
|
||||
asyncio.create_task(self._emit(message, buffer=True))
|
||||
if kind in ("done", "finished") and row.get("notify"):
|
||||
self._deliver_reminder(row, payload)
|
||||
|
||||
def _deliver_reminder(self, row: dict[str, Any], payload: str) -> None:
|
||||
if self.owner_kind != "user" or not self.owner_id:
|
||||
return
|
||||
text = (payload or "").strip() or (
|
||||
row.get("label") or "Your scheduled reminder fired."
|
||||
)
|
||||
try:
|
||||
from devplacepy.utils import create_notification
|
||||
|
||||
create_notification(
|
||||
self.owner_id,
|
||||
"reminder",
|
||||
text[:500],
|
||||
row.get("uid", ""),
|
||||
target_url="/devii",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a reminder notification must never crash the scheduler
|
||||
logger.exception(
|
||||
"Failed to deliver reminder notification for %s/%s",
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
)
|
||||
|
||||
|
||||
NON_ADMIN_COST_RULE = (
|
||||
@@ -792,3 +951,12 @@ def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _format_offset(offset: Any) -> str:
|
||||
if offset is None:
|
||||
return "+00:00"
|
||||
total_minutes = int(offset.total_seconds() // 60)
|
||||
sign = "+" if total_minutes >= 0 else "-"
|
||||
total_minutes = abs(total_minutes)
|
||||
return f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}"
|
||||
|
||||
@@ -24,7 +24,7 @@ def _now() -> datetime:
|
||||
|
||||
|
||||
def _iso(moment: datetime) -> str:
|
||||
return moment.isoformat()
|
||||
return moment.isoformat(timespec="microseconds")
|
||||
|
||||
|
||||
class ConversationStore:
|
||||
|
||||
@@ -77,6 +77,13 @@ TASK_ACTIONS: tuple[Action, ...] = (
|
||||
required=True,
|
||||
),
|
||||
field("label", "Optional short human-readable label for the task."),
|
||||
field(
|
||||
"notify",
|
||||
"Set true for a REMINDER: when the task fires, the user is sent an in-app "
|
||||
"notification and live toast carrying the result, so it reaches them even when "
|
||||
"the Devii terminal is closed. Leave false for silent background automation.",
|
||||
kind="boolean",
|
||||
),
|
||||
*SCHEDULE_FIELDS,
|
||||
),
|
||||
),
|
||||
@@ -116,6 +123,11 @@ TASK_ACTIONS: tuple[Action, ...] = (
|
||||
field("prompt", "New prompt."),
|
||||
field("label", "New label."),
|
||||
field("enabled", "Enable or disable the task.", kind="boolean"),
|
||||
field(
|
||||
"notify",
|
||||
"Enable or disable the in-app reminder notification when this task fires.",
|
||||
kind="boolean",
|
||||
),
|
||||
field("kind", "New schedule type when rescheduling."),
|
||||
field("run_at", "New absolute run time for kind=once."),
|
||||
field("delay_seconds", "New relative delay for kind=once.", kind="integer"),
|
||||
|
||||
@@ -51,6 +51,8 @@ def _serialize(row: dict[str, Any], preview: bool) -> dict[str, Any]:
|
||||
"every_seconds": row.get("every_seconds"),
|
||||
"cron": row.get("cron"),
|
||||
"run_at": row.get("run_at"),
|
||||
"notify": bool(row.get("notify")),
|
||||
"tz": row.get("tz") or None,
|
||||
}
|
||||
last_error = row.get("last_error")
|
||||
if last_error:
|
||||
@@ -98,6 +100,8 @@ class TaskController:
|
||||
"run_count": 0,
|
||||
"last_result": None,
|
||||
"last_error": None,
|
||||
"notify": 1 if _as_bool(arguments.get("notify"), default=False) else 0,
|
||||
"tz": (arguments.get("tz") or "").strip() or None,
|
||||
**schedule.columns(),
|
||||
}
|
||||
self._store.create(record)
|
||||
@@ -137,6 +141,10 @@ class TaskController:
|
||||
changes["label"] = (arguments.get("label") or "").strip() or None
|
||||
if "enabled" in arguments:
|
||||
changes["enabled"] = _as_bool(arguments.get("enabled"))
|
||||
if "notify" in arguments and arguments["notify"] is not None:
|
||||
changes["notify"] = 1 if _as_bool(arguments.get("notify")) else 0
|
||||
if "tz" in arguments and arguments["tz"] is not None:
|
||||
changes["tz"] = (str(arguments.get("tz")) or "").strip() or None
|
||||
|
||||
if any(
|
||||
key in arguments and arguments[key] is not None for key in SCHEDULE_KEYS
|
||||
|
||||
@@ -24,6 +24,24 @@ def memory_db() -> Any:
|
||||
return dataset.connect("sqlite:///:memory:")
|
||||
|
||||
|
||||
ACTIVE_STATUSES = ("pending", "running")
|
||||
|
||||
|
||||
def pending_owner_ids(db: Any) -> list[str]:
|
||||
if TABLE not in db.tables:
|
||||
return []
|
||||
table = db[TABLE]
|
||||
if "owner_id" not in table.columns:
|
||||
return []
|
||||
rows = table.find(owner_kind="user", enabled=True, deleted_at=None)
|
||||
owners = {
|
||||
row["owner_id"]
|
||||
for row in rows
|
||||
if row.get("owner_id") and row.get("status") in ACTIVE_STATUSES
|
||||
}
|
||||
return sorted(owners)
|
||||
|
||||
|
||||
class TaskStore:
|
||||
def __init__(self, db: Any, owner_kind: str, owner_id: str) -> None:
|
||||
self._db = db
|
||||
@@ -39,6 +57,10 @@ class TaskStore:
|
||||
table.create_column_by_example("deleted_at", "")
|
||||
if not table.has_column("deleted_by"):
|
||||
table.create_column_by_example("deleted_by", "")
|
||||
if not table.has_column("notify"):
|
||||
table.create_column_by_example("notify", 0)
|
||||
if not table.has_column("tz"):
|
||||
table.create_column_by_example("tz", "")
|
||||
for columns in INDEXED_COLUMNS:
|
||||
table.create_index(columns)
|
||||
|
||||
@@ -109,6 +131,10 @@ class TaskStore:
|
||||
logger.info("Recovered %d task(s) stuck in running", len(stuck))
|
||||
return len(stuck)
|
||||
|
||||
def has_pending(self) -> bool:
|
||||
rows = self._table.find(enabled=True, deleted_at=None, **self._scope)
|
||||
return any(row.get("status") in ACTIVE_STATUSES for row in rows)
|
||||
|
||||
def due(self, now_iso: str) -> list[dict[str, Any]]:
|
||||
rows = self._table.find(
|
||||
enabled=True, status="pending", deleted_at=None, **self._scope
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .controller import TelegramSendController
|
||||
|
||||
__all__ = ["TelegramSendController"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
|
||||
|
||||
class TelegramSendController:
|
||||
def __init__(self, owner_kind: str, owner_id: str) -> None:
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name == "telegram_send":
|
||||
return await self._send(arguments)
|
||||
raise ToolInputError(f"Unknown telegram tool: {name}")
|
||||
|
||||
async def _send(self, arguments: dict[str, Any]) -> str:
|
||||
if self._owner_kind != "user":
|
||||
raise ToolInputError(
|
||||
"Telegram delivery is only available for signed-in users."
|
||||
)
|
||||
text = str(arguments.get("text", "")).strip()
|
||||
if not text:
|
||||
raise ToolInputError("'text' is required and cannot be empty.")
|
||||
from devplacepy.services.telegram import store
|
||||
|
||||
link = store.link_for_user(self._owner_id)
|
||||
if not link:
|
||||
raise ToolInputError(
|
||||
"Your Telegram account is not paired. Ask the user to request a pairing "
|
||||
"code from their profile and connect Telegram first."
|
||||
)
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
service = service_manager.get_service("telegram")
|
||||
if (
|
||||
service is None
|
||||
or not service.is_enabled()
|
||||
or not service_manager.owns_lock()
|
||||
or not service.worker_alive()
|
||||
):
|
||||
raise ToolInputError("The Telegram bot service is not running right now.")
|
||||
delivered = await service.send_markdown(int(link["chat_id"]), text)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success" if delivered else "failed",
|
||||
"delivered": delivered,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
Reference in New Issue
Block a user