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:
@@ -309,6 +309,7 @@ SOFT_DELETE_TABLES = [
|
||||
"deepsearch_messages",
|
||||
"devrant_tokens",
|
||||
"access_tokens",
|
||||
"email_accounts",
|
||||
]
|
||||
|
||||
|
||||
@@ -353,6 +354,7 @@ def init_db():
|
||||
_index(db, "users", "idx_users_username", ["username"])
|
||||
_index(db, "users", "idx_users_email", ["email"])
|
||||
_index(db, "users", "idx_users_api_key", ["api_key"])
|
||||
_index(db, "users", "idx_users_role", ["role", "created_at"])
|
||||
_index(db, "posts", "idx_posts_user_uid", ["user_uid"])
|
||||
_index(db, "posts", "idx_posts_created_at", ["created_at"])
|
||||
_index(db, "posts", "idx_posts_topic", ["topic"])
|
||||
@@ -388,6 +390,33 @@ def init_db():
|
||||
access_tokens.create_column_by_example(column, example)
|
||||
_index(db, "access_tokens", "idx_access_tokens_token", ["token"])
|
||||
_index(db, "access_tokens", "idx_access_tokens_user", ["user_uid"])
|
||||
if "telegram_pairings" in tables:
|
||||
telegram_pairings = get_table("telegram_pairings")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("user_uid", ""),
|
||||
("code_hash", ""),
|
||||
("expires_at", ""),
|
||||
("used", 0),
|
||||
("created_at", ""),
|
||||
):
|
||||
if not telegram_pairings.has_column(column):
|
||||
telegram_pairings.create_column_by_example(column, example)
|
||||
_index(db, "telegram_pairings", "idx_telegram_pairings_code", ["code_hash"])
|
||||
_index(db, "telegram_pairings", "idx_telegram_pairings_user", ["user_uid"])
|
||||
if "telegram_links" in tables:
|
||||
telegram_links = get_table("telegram_links")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("user_uid", ""),
|
||||
("chat_id", 0),
|
||||
("from_id", 0),
|
||||
("created_at", ""),
|
||||
):
|
||||
if not telegram_links.has_column(column):
|
||||
telegram_links.create_column_by_example(column, example)
|
||||
_index(db, "telegram_links", "idx_telegram_links_chat", ["chat_id"])
|
||||
_index(db, "telegram_links", "idx_telegram_links_user", ["user_uid"])
|
||||
_index(db, "comments", "idx_comments_post_uid", ["post_uid"])
|
||||
_index(db, "comments", "idx_comments_target", ["target_type", "target_uid"])
|
||||
_index(db, "comments", "idx_comments_user_uid", ["user_uid"])
|
||||
@@ -399,8 +428,15 @@ def init_db():
|
||||
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
|
||||
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
|
||||
_index(db, "sessions", "idx_sessions_token", ["session_token"])
|
||||
projects = get_table("projects")
|
||||
for column, example in (("stars", 0), ("project_type", "")):
|
||||
if not projects.has_column(column):
|
||||
projects.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "projects", "idx_projects_user", ["user_uid"])
|
||||
_index(db, "projects", "idx_projects_private", ["is_private"])
|
||||
_index(db, "projects", "idx_projects_stars", ["stars"])
|
||||
_index(db, "projects", "idx_projects_type", ["project_type"])
|
||||
_index(db, "project_files", "idx_project_files_path", ["project_uid", "path"])
|
||||
_index(
|
||||
db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"]
|
||||
@@ -496,6 +532,7 @@ def init_db():
|
||||
_index(db, "news", "idx_news_synced_at", ["synced_at"])
|
||||
_index(db, "news", "idx_news_status", ["status"])
|
||||
_index(db, "news", "idx_news_featured", ["featured"])
|
||||
_index(db, "news", "idx_news_landing", ["show_on_landing"])
|
||||
|
||||
news_images = get_table("news_images")
|
||||
for column, example in (
|
||||
@@ -765,6 +802,39 @@ def init_db():
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create unique index on modifier_usage: {e}")
|
||||
|
||||
email_accounts = get_table("email_accounts")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("label", ""),
|
||||
("imap_host", ""),
|
||||
("imap_port", 993),
|
||||
("imap_ssl", 1),
|
||||
("imap_starttls", 0),
|
||||
("smtp_host", ""),
|
||||
("smtp_port", 587),
|
||||
("smtp_ssl", 0),
|
||||
("smtp_starttls", 1),
|
||||
("username", ""),
|
||||
("password", ""),
|
||||
("from_address", ""),
|
||||
("from_name", ""),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not email_accounts.has_column(column):
|
||||
email_accounts.create_column_by_example(column, example)
|
||||
_index(db, "email_accounts", "idx_email_accounts_owner", ["owner_kind", "owner_id"])
|
||||
_index(
|
||||
db,
|
||||
"email_accounts",
|
||||
"idx_email_accounts_label",
|
||||
["owner_kind", "owner_id", "label"],
|
||||
)
|
||||
|
||||
user_activity = get_table("user_activity")
|
||||
for column, example in (
|
||||
("user_uid", ""),
|
||||
@@ -1013,6 +1083,8 @@ def backfill_api_keys() -> int:
|
||||
users.create_column_by_example("ai_modifier_sync", 1)
|
||||
if not users.has_column("ai_modifier_prompt"):
|
||||
users.create_column_by_example("ai_modifier_prompt", DEFAULT_MODIFIER_PROMPT)
|
||||
if not users.has_column("timezone"):
|
||||
users.create_column_by_example("timezone", "")
|
||||
with db:
|
||||
db.query(
|
||||
"UPDATE users SET ai_modifier_enabled = 1 WHERE ai_modifier_enabled IS NULL"
|
||||
@@ -1259,6 +1331,18 @@ def get_admin_uids():
|
||||
return [row["uid"] for row in rows]
|
||||
|
||||
|
||||
def set_user_timezone(user_uid: str, tz_name: str) -> None:
|
||||
if "users" not in db.tables or not user_uid or not tz_name:
|
||||
return
|
||||
users = db["users"]
|
||||
if not users.has_column("timezone"):
|
||||
users.create_column_by_example("timezone", "")
|
||||
current = users.find_one(uid=user_uid)
|
||||
if current and current.get("timezone") == tz_name:
|
||||
return
|
||||
users.update({"uid": user_uid, "timezone": tz_name}, ["uid"])
|
||||
|
||||
|
||||
def get_primary_admin_uid():
|
||||
if "users" not in db.tables:
|
||||
return None
|
||||
@@ -1880,6 +1964,97 @@ def delete_custom_override(
|
||||
return int(count)
|
||||
|
||||
|
||||
EMAIL_ACCOUNT_DEFAULTS: dict[str, object] = {
|
||||
"imap_host": "",
|
||||
"imap_port": 993,
|
||||
"imap_ssl": 1,
|
||||
"imap_starttls": 0,
|
||||
"smtp_host": "",
|
||||
"smtp_port": 587,
|
||||
"smtp_ssl": 0,
|
||||
"smtp_starttls": 1,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"from_address": "",
|
||||
"from_name": "",
|
||||
}
|
||||
|
||||
|
||||
def list_email_accounts(owner_kind: str, owner_id: str) -> list:
|
||||
if "email_accounts" not in db.tables:
|
||||
return []
|
||||
return list(
|
||||
db["email_accounts"].find(
|
||||
owner_kind=owner_kind, owner_id=owner_id, deleted_at=None
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_email_account(owner_kind: str, owner_id: str, label: str) -> dict | None:
|
||||
if "email_accounts" not in db.tables:
|
||||
return None
|
||||
return db["email_accounts"].find_one(
|
||||
owner_kind=owner_kind, owner_id=owner_id, label=label, deleted_at=None
|
||||
)
|
||||
|
||||
|
||||
def set_email_account(
|
||||
owner_kind: str, owner_id: str, label: str, fields: dict
|
||||
) -> dict:
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
table = get_table("email_accounts")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
existing = table.find_one(owner_kind=owner_kind, owner_id=owner_id, label=label)
|
||||
values = {**EMAIL_ACCOUNT_DEFAULTS, **(existing or {}), **fields}
|
||||
if not values.get("from_address"):
|
||||
values["from_address"] = values.get("username") or ""
|
||||
record = {
|
||||
key: values.get(key, default)
|
||||
for key, default in EMAIL_ACCOUNT_DEFAULTS.items()
|
||||
}
|
||||
if existing:
|
||||
record.update(
|
||||
{
|
||||
"id": existing["id"],
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
table.update(record, ["id"])
|
||||
result = {**existing, **record}
|
||||
else:
|
||||
result = {
|
||||
"uid": generate_uid(),
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
"label": label,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
**record,
|
||||
}
|
||||
table.insert(result)
|
||||
return result
|
||||
|
||||
|
||||
def delete_email_account(
|
||||
owner_kind: str, owner_id: str, label: str, deleted_by: str | None = None
|
||||
) -> int:
|
||||
if "email_accounts" not in db.tables:
|
||||
return 0
|
||||
count = soft_delete(
|
||||
"email_accounts",
|
||||
deleted_by or f"{owner_kind}:{owner_id}",
|
||||
owner_kind=owner_kind,
|
||||
owner_id=owner_id,
|
||||
label=label,
|
||||
)
|
||||
return int(count)
|
||||
|
||||
|
||||
NOTIFICATION_TYPES = [
|
||||
{"key": "comment", "label": "Comments", "description": "Someone comments on your post"},
|
||||
{"key": "reply", "label": "Replies", "description": "Someone replies to your comment"},
|
||||
@@ -1890,6 +2065,7 @@ NOTIFICATION_TYPES = [
|
||||
{"key": "badge", "label": "Badges", "description": "You earn a badge"},
|
||||
{"key": "level", "label": "Level-ups", "description": "You reach a new level"},
|
||||
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
|
||||
{"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"},
|
||||
]
|
||||
|
||||
NOTIFICATION_CHANNELS = ("in_app", "push")
|
||||
|
||||
@@ -1794,6 +1794,41 @@ four ways to sign requests.
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-telegram",
|
||||
method="POST",
|
||||
path="/profile/{username}/telegram",
|
||||
title="Pair or unpair Telegram",
|
||||
summary="Request a single-use Telegram pairing code, or unpair the connected account. Send action=request (default) to receive a code valid for a few minutes, or action=unpair to disconnect. Admins may target any user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Profile owner. Must be yourself unless you are an admin.",
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"request",
|
||||
"Either request to issue a pairing code, or unpair to disconnect Telegram.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"paired": False,
|
||||
"code": "1234",
|
||||
"expires_at": "2026-06-18T12:05:00+00:00",
|
||||
"ttl_minutes": 5,
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-regenerate-key",
|
||||
method="POST",
|
||||
|
||||
@@ -101,6 +101,7 @@ from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.xmlrpc import XmlrpcService
|
||||
from devplacepy.services.audit import AuditService
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.telegram import TelegramService
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -211,6 +212,7 @@ async def lifespan(app: FastAPI):
|
||||
service_manager.register(ContainerService())
|
||||
service_manager.register(XmlrpcService())
|
||||
service_manager.register(AuditService())
|
||||
service_manager.register(TelegramService())
|
||||
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
||||
await background.start()
|
||||
if acquire_service_lock():
|
||||
@@ -501,6 +503,15 @@ async def maintenance_middleware(request: Request, call_next):
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def response_timing(request: Request, call_next):
|
||||
start = time.perf_counter()
|
||||
request.state.request_start = start
|
||||
response = await call_next(request)
|
||||
response.headers["X-Response-Time"] = f"{(time.perf_counter() - start) * 1000:.1f}ms"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def landing(request: Request):
|
||||
user = get_current_user(request)
|
||||
|
||||
@@ -260,6 +260,10 @@ class AiModifierForm(BaseModel):
|
||||
prompt: str = Field(default=DEFAULT_MODIFIER_PROMPT, max_length=2000)
|
||||
|
||||
|
||||
class TelegramPairForm(BaseModel):
|
||||
action: str = Field(default="request", max_length=16)
|
||||
|
||||
|
||||
class ForkForm(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
@@ -66,6 +66,26 @@ async def guard_public_url(url: str, *, allow_private: bool = False) -> str:
|
||||
return host
|
||||
|
||||
|
||||
def guard_public_host_sync(host: str) -> str:
|
||||
if not host:
|
||||
raise BlockedAddressError("No host provided.")
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror as exc:
|
||||
raise BlockedAddressError(f"Could not resolve host: {host}") from exc
|
||||
for info in infos:
|
||||
address = ipaddress.ip_address(info[4][0])
|
||||
if is_blocked_address(address):
|
||||
raise BlockedAddressError(
|
||||
f"Refusing to reach a private or local address ({effective_address(address)})."
|
||||
)
|
||||
return host
|
||||
|
||||
|
||||
async def guard_public_host(host: str) -> str:
|
||||
return await asyncio.to_thread(guard_public_host_sync, host)
|
||||
|
||||
|
||||
class _GuardedTransport(httpx.AsyncBaseTransport):
|
||||
def __init__(self, inner: httpx.AsyncBaseTransport) -> None:
|
||||
self._inner = inner
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import html
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from html.parser import HTMLParser
|
||||
|
||||
import mistune
|
||||
from markupsafe import Markup
|
||||
|
||||
EMOJI_MAP = {
|
||||
"grinning": "\U0001F600", "smiley": "\U0001F603", "smile": "\U0001F604",
|
||||
"grin": "\U0001F601", "laughing": "\U0001F606", "sweat_smile": "\U0001F605",
|
||||
"joy": "\U0001F602", "blush": "\U0001F60A", "innocent": "\U0001F607",
|
||||
"wink": "\U0001F609", "heart_eyes": "\U0001F60D", "kissing_heart": "\U0001F618",
|
||||
"stuck_out_tongue": "\U0001F61B", "stuck_out_tongue_winking_eye": "\U0001F61C",
|
||||
"sunglasses": "\U0001F60E", "unamused": "\U0001F612", "sweat": "\U0001F613",
|
||||
"sob": "\U0001F62D", "scream": "\U0001F631", "sleeping": "\U0001F634",
|
||||
"relieved": "\U0001F60C", "heart": "❤️", "broken_heart": "\U0001F494",
|
||||
"two_hearts": "\U0001F495", "sparkling_heart": "\U0001F496",
|
||||
"star": "⭐", "sparkles": "✨", "zap": "⚡",
|
||||
"fire": "\U0001F525", "rocket": "\U0001F680", "100": "\U0001F4AF",
|
||||
"clap": "\U0001F44F", "ok_hand": "\U0001F44C", "wave": "\U0001F44B",
|
||||
"thumbsup": "\U0001F44D", "+1": "\U0001F44D", "thumbsdown": "\U0001F44E",
|
||||
"-1": "\U0001F44E", "pray": "\U0001F64F", "muscle": "\U0001F4AA",
|
||||
"tada": "\U0001F389", "confetti_ball": "\U0001F38A", "party": "\U0001F973",
|
||||
"beers": "\U0001F37B", "coffee": "☕", "pizza": "\U0001F355",
|
||||
"computer": "\U0001F4BB", "bug": "\U0001F41B", "gear": "⚙️",
|
||||
"warning": "⚠️", "question": "❓", "exclamation": "❗",
|
||||
"checkered_flag": "\U0001F3C1", "eyes": "\U0001F440", "brain": "\U0001F9E0",
|
||||
"robot": "\U0001F916", "skull": "\U0001F480", "point_up": "☝️",
|
||||
"point_down": "\U0001F447", "point_left": "\U0001F448", "point_right": "\U0001F449",
|
||||
"pencil2": "✏️", "memo": "\U0001F4DD", "book": "\U0001F4D6",
|
||||
"tools": "\U0001F6E0️", "hammer_and_wrench": "\U0001F6E0️",
|
||||
}
|
||||
|
||||
_SHORTCODE_RE = re.compile(r":([A-Za-z0-9_+\-]+):")
|
||||
_YOUTUBE_RE = re.compile(
|
||||
r"(?:https?://)?(?:www\.)?"
|
||||
r"(?:youtube\.com/(?:watch\?v=|embed/|v/|shorts/)|youtu\.be/)"
|
||||
r"([A-Za-z0-9_-]{11})"
|
||||
)
|
||||
_IMAGE_RE = re.compile(r"\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?.*)?$", re.I)
|
||||
_VIDEO_RE = re.compile(r"\.(mp4|webm|ogg|ogv|mov|m4v)(\?.*)?$", re.I)
|
||||
_AUDIO_RE = re.compile(r"\.(mp3|wav|flac|ogg|aac|m4a|wma|opus)(\?.*)?$", re.I)
|
||||
_TOKEN_RE = re.compile(
|
||||
r"(?P<url>https?://[^\s<]+)"
|
||||
r"|(?<![A-Za-z0-9_@])@(?P<mention>[A-Za-z0-9_-]+)"
|
||||
)
|
||||
_SCHEME_RE = re.compile(r"^([a-z][a-z0-9+.\-]*):", re.I)
|
||||
_ALLOWED_SCHEMES = {"http", "https", "mailto", "tel"}
|
||||
|
||||
_YOUTUBE_ALLOW = (
|
||||
"accelerometer; autoplay; clipboard-write; encrypted-media; "
|
||||
"gyroscope; picture-in-picture"
|
||||
)
|
||||
|
||||
_MEDIA_SKIP_TAGS = {"a", "code", "pre"}
|
||||
_TITLE_INLINE_TAGS = {
|
||||
"b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br",
|
||||
}
|
||||
|
||||
|
||||
def _safe_url(url: str) -> str:
|
||||
value = (url or "").strip()
|
||||
match = _SCHEME_RE.match(value)
|
||||
if match and match.group(1).lower() not in _ALLOWED_SCHEMES:
|
||||
return "#"
|
||||
return value
|
||||
|
||||
|
||||
class _ContentRenderer(mistune.HTMLRenderer):
|
||||
def link(self, text: str, url: str, title=None) -> str:
|
||||
return super().link(text, _safe_url(url), title)
|
||||
|
||||
def image(self, text: str, url: str, title=None) -> str:
|
||||
src = html.escape(_safe_url(url), quote=True)
|
||||
alt = html.escape(text or "", quote=True)
|
||||
title_attr = f' title="{html.escape(title, quote=True)}"' if title else ""
|
||||
return (
|
||||
f'<img src="{src}" alt="{alt}"{title_attr} '
|
||||
f'loading="lazy" data-lightbox>'
|
||||
)
|
||||
|
||||
|
||||
_content_markdown = mistune.create_markdown(
|
||||
hard_wrap=True,
|
||||
renderer=_ContentRenderer(escape=True),
|
||||
plugins=["strikethrough", "table"],
|
||||
)
|
||||
|
||||
|
||||
def _normalize_dashes(text: str) -> str:
|
||||
return text.replace("\u2014", "-")
|
||||
|
||||
|
||||
def _replace_shortcodes(text: str) -> str:
|
||||
return _SHORTCODE_RE.sub(
|
||||
lambda m: EMOJI_MAP.get(m.group(1).lower(), m.group(0)), text
|
||||
)
|
||||
|
||||
|
||||
def _embed_url(url: str) -> str:
|
||||
youtube = _YOUTUBE_RE.search(url)
|
||||
if youtube:
|
||||
video_id = youtube.group(1)
|
||||
return (
|
||||
f'<div class="embed-youtube"><iframe '
|
||||
f'src="https://www.youtube.com/embed/{video_id}" '
|
||||
f'frameborder="0" allowfullscreen allow="{_YOUTUBE_ALLOW}">'
|
||||
f"</iframe></div>"
|
||||
)
|
||||
escaped = html.escape(url, quote=True)
|
||||
if _IMAGE_RE.search(url):
|
||||
return f'<img src="{escaped}" alt="" loading="lazy" data-lightbox>'
|
||||
if _VIDEO_RE.search(url):
|
||||
return f'<video src="{escaped}" controls preload="metadata"></video>'
|
||||
if _AUDIO_RE.search(url):
|
||||
return f'<audio src="{escaped}" controls preload="metadata"></audio>'
|
||||
return (
|
||||
f'<a href="{escaped}" target="_blank" rel="noopener noreferrer">'
|
||||
f"{html.escape(url)}</a>"
|
||||
)
|
||||
|
||||
|
||||
def _transform_text(text: str) -> str:
|
||||
out: list[str] = []
|
||||
pos = 0
|
||||
for match in _TOKEN_RE.finditer(text):
|
||||
if match.start() > pos:
|
||||
out.append(html.escape(text[pos:match.start()]))
|
||||
if match.group("url"):
|
||||
out.append(_embed_url(match.group("url")))
|
||||
else:
|
||||
user = match.group("mention")
|
||||
out.append(
|
||||
f'<a href="/profile/{html.escape(user, quote=True)}" '
|
||||
f'class="mention-link">@{html.escape(user)}</a>'
|
||||
)
|
||||
pos = match.end()
|
||||
if pos < len(text):
|
||||
out.append(html.escape(text[pos:]))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _emit_tag(tag: str, attrs: list, self_closing: bool = False) -> str:
|
||||
parts = [f"<{tag}"]
|
||||
for key, value in attrs:
|
||||
if value is None:
|
||||
parts.append(f" {key}")
|
||||
else:
|
||||
parts.append(f' {key}="{html.escape(value, quote=True)}"')
|
||||
parts.append(" />" if self_closing else ">")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
class _MediaProcessor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self._out: list[str] = []
|
||||
self._skip_depth = 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list) -> None:
|
||||
self._out.append(_emit_tag(tag, attrs))
|
||||
if tag in _MEDIA_SKIP_TAGS:
|
||||
self._skip_depth += 1
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list) -> None:
|
||||
self._out.append(_emit_tag(tag, attrs, self_closing=True))
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in _MEDIA_SKIP_TAGS and self._skip_depth > 0:
|
||||
self._skip_depth -= 1
|
||||
self._out.append(f"</{tag}>")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._skip_depth > 0:
|
||||
self._out.append(html.escape(data))
|
||||
else:
|
||||
self._out.append(_transform_text(data))
|
||||
|
||||
def result(self) -> str:
|
||||
return "".join(self._out)
|
||||
|
||||
|
||||
class _InlineFilter(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self._out: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list) -> None:
|
||||
if tag in _TITLE_INLINE_TAGS:
|
||||
self._out.append(f"<{tag}>")
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list) -> None:
|
||||
if tag in _TITLE_INLINE_TAGS:
|
||||
self._out.append(f"<{tag}>")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in _TITLE_INLINE_TAGS and tag != "br":
|
||||
self._out.append(f"</{tag}>")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self._out.append(html.escape(data))
|
||||
|
||||
def result(self) -> str:
|
||||
return "".join(self._out).strip()
|
||||
|
||||
|
||||
def _process_media(rendered: str) -> str:
|
||||
processor = _MediaProcessor()
|
||||
processor.feed(rendered)
|
||||
processor.close()
|
||||
return processor.result()
|
||||
|
||||
|
||||
def _keep_inline(rendered: str) -> str:
|
||||
inline = _InlineFilter()
|
||||
inline.feed(rendered)
|
||||
inline.close()
|
||||
return inline.result()
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _render_content(text: str) -> str:
|
||||
text = _normalize_dashes(text)
|
||||
text = _replace_shortcodes(text)
|
||||
return _process_media(_content_markdown(text))
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _render_title(text: str) -> str:
|
||||
text = _normalize_dashes(text)
|
||||
text = _replace_shortcodes(text)
|
||||
return _keep_inline(_content_markdown(text))
|
||||
|
||||
|
||||
def render_content(text) -> Markup:
|
||||
if not text:
|
||||
return Markup("")
|
||||
return Markup(_render_content(str(text)))
|
||||
|
||||
|
||||
def render_title(text) -> Markup:
|
||||
if not text:
|
||||
return Markup("")
|
||||
return Markup(_render_title(str(text)))
|
||||
@@ -61,7 +61,7 @@ def _targets() -> list[dict]:
|
||||
for key, meta in store.BACKUP_TARGETS.items()
|
||||
]
|
||||
|
||||
def _dashboard(request: Request, can_download: bool) -> dict:
|
||||
def _dashboard(can_download: bool) -> dict:
|
||||
backups = [_backup_payload(row, can_download) for row in store.list_backups()]
|
||||
schedules = store.list_schedules()
|
||||
return {
|
||||
@@ -77,7 +77,7 @@ def _dashboard(request: Request, can_download: bool) -> dict:
|
||||
@router.get("/backups", response_class=HTMLResponse)
|
||||
async def admin_backups(request: Request):
|
||||
admin = require_admin(request)
|
||||
data = _dashboard(request, is_primary_admin(admin))
|
||||
data = _dashboard(is_primary_admin(admin))
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
@@ -106,7 +106,7 @@ async def admin_backups(request: Request):
|
||||
@router.get("/backups/data")
|
||||
async def admin_backups_data(request: Request):
|
||||
admin = require_admin(request)
|
||||
data = _dashboard(request, is_primary_admin(admin))
|
||||
data = _dashboard(is_primary_admin(admin))
|
||||
return JSONResponse(BackupDashboardOut.model_validate(data).model_dump(mode="json"))
|
||||
|
||||
@router.post("/backups/run")
|
||||
|
||||
@@ -19,7 +19,7 @@ from devplacepy.utils import (
|
||||
)
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import AdminUsersOut
|
||||
from devplacepy.schemas import AdminUsersOut, UserAiUsageOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.openai_gateway.analytics import build_user_usage
|
||||
@@ -62,7 +62,8 @@ async def admin_user_ai_usage(request: Request, uid: str, hours: int = 24):
|
||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
||||
service = service_manager.get_service("openai")
|
||||
pricing = pricing_from_cfg(service.get_config()) if service is not None else None
|
||||
return JSONResponse(build_user_usage(uid, hours=hours, pricing=pricing))
|
||||
usage = build_user_usage(uid, hours=hours, pricing=pricing)
|
||||
return JSONResponse(UserAiUsageOut(**usage).model_dump())
|
||||
|
||||
@router.get("/users", response_class=HTMLResponse)
|
||||
async def admin_users(request: Request, page: int = 1):
|
||||
|
||||
@@ -11,6 +11,7 @@ from devplacepy.utils import (
|
||||
create_session,
|
||||
get_current_user,
|
||||
safe_next,
|
||||
cookie_secure,
|
||||
)
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.models import LoginForm
|
||||
@@ -95,7 +96,12 @@ async def login(request: Request, data: Annotated[LoginForm, Depends(json_or_for
|
||||
request, safe_next(data.next), data={"username": user["username"]}
|
||||
)
|
||||
response.set_cookie(
|
||||
key="session", value=token, max_age=max_age, httponly=True, samesite="lax"
|
||||
key="session",
|
||||
value=token,
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=cookie_secure(request),
|
||||
)
|
||||
logger.info(f"User {user['username']} logged in")
|
||||
audit.record(
|
||||
|
||||
@@ -10,6 +10,7 @@ from devplacepy.utils import (
|
||||
create_session,
|
||||
get_current_user,
|
||||
register_account_async,
|
||||
cookie_secure,
|
||||
)
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.models import SignupForm
|
||||
@@ -95,7 +96,12 @@ async def signup(request: Request, data: Annotated[SignupForm, Depends(json_or_f
|
||||
token = create_session(uid, max_age)
|
||||
response = action_result(request, "/feed", data={"username": username})
|
||||
response.set_cookie(
|
||||
key="session", value=token, max_age=max_age, httponly=True, samesite="lax"
|
||||
key="session",
|
||||
value=token,
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=cookie_secure(request),
|
||||
)
|
||||
logger.info(f"User {username} signed up")
|
||||
audit.record(
|
||||
|
||||
@@ -25,9 +25,7 @@ DEFAULT_MAX_ROWS = 5000
|
||||
|
||||
|
||||
def _owner(caller) -> tuple[str, str]:
|
||||
if caller.kind == "admin":
|
||||
return "user", caller.uid
|
||||
return "internal", caller.uid
|
||||
return "user", caller.uid
|
||||
|
||||
|
||||
def _max_rows() -> int:
|
||||
|
||||
@@ -19,6 +19,7 @@ from devplacepy.utils import (
|
||||
_user_from_session,
|
||||
get_current_user,
|
||||
is_admin,
|
||||
is_primary_admin,
|
||||
require_user,
|
||||
)
|
||||
from devplacepy.services.audit import record as audit
|
||||
@@ -59,9 +60,10 @@ def _resolve_ws_owner(websocket: WebSocket):
|
||||
user.get("username", ""),
|
||||
user.get("api_key", ""),
|
||||
is_admin(user),
|
||||
is_primary_admin(user),
|
||||
)
|
||||
guest_id = websocket.cookies.get(GUEST_COOKIE) or uuid_utils.uuid7().hex
|
||||
return "guest", guest_id, "guest", "", False
|
||||
guest_id = websocket.cookies.get(GUEST_COOKIE) or ""
|
||||
return "guest", guest_id, "guest", "", False, False
|
||||
|
||||
|
||||
def _owner_from_request(request: Request):
|
||||
@@ -157,8 +159,9 @@ async def devii_usage(request: Request):
|
||||
owner_kind, owner_id, _ = _owner_from_request(request)
|
||||
if svc is None:
|
||||
return JSONResponse({"used_pct": 0.0, "turns_today": 0})
|
||||
viewer_is_admin = is_admin(get_current_user(request))
|
||||
spent = svc.spent_24h(owner_kind, owner_id) if owner_id else 0.0
|
||||
limit = svc.daily_limit_for(owner_kind)
|
||||
limit = svc.daily_limit_for(owner_kind, viewer_is_admin)
|
||||
turns = svc.hub().ledger.turns_24h(owner_kind, owner_id) if owner_id else 0
|
||||
used_pct = round(min(100.0, spent / limit * 100), 1) if limit > 0 else 0.0
|
||||
payload = {
|
||||
@@ -166,7 +169,7 @@ async def devii_usage(request: Request):
|
||||
"turns_today": turns,
|
||||
"owner_kind": owner_kind,
|
||||
}
|
||||
if is_admin(get_current_user(request)):
|
||||
if viewer_is_admin:
|
||||
payload["spent_24h"] = round(spent, 6)
|
||||
payload["limit"] = limit
|
||||
return JSONResponse(payload)
|
||||
@@ -212,8 +215,8 @@ async def devii_ws(websocket: WebSocket):
|
||||
channel = websocket.query_params.get("channel", "main")
|
||||
if channel not in CHANNELS:
|
||||
channel = "main"
|
||||
owner_kind, owner_id, username, api_key, owner_is_admin = _resolve_ws_owner(
|
||||
websocket
|
||||
owner_kind, owner_id, username, api_key, owner_is_admin, owner_is_primary_admin = (
|
||||
_resolve_ws_owner(websocket)
|
||||
)
|
||||
if owner_kind == "guest" and not svc.guests_enabled():
|
||||
await websocket.send_json(
|
||||
@@ -221,6 +224,12 @@ async def devii_ws(websocket: WebSocket):
|
||||
)
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
if owner_kind == "guest" and not owner_id:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "text": "Open Devii from the page to start a session."}
|
||||
)
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
session = svc.hub().get_or_create(
|
||||
owner_kind,
|
||||
@@ -229,6 +238,7 @@ async def devii_ws(websocket: WebSocket):
|
||||
api_key,
|
||||
svc.instance_base_url(),
|
||||
is_admin=owner_is_admin,
|
||||
is_primary_admin=owner_is_primary_admin,
|
||||
channel=channel,
|
||||
)
|
||||
session.attach(websocket)
|
||||
@@ -248,9 +258,8 @@ async def devii_ws(websocket: WebSocket):
|
||||
if command == "reset":
|
||||
await session.reset()
|
||||
continue
|
||||
limit = svc.daily_limit_for(owner_kind, owner_is_admin)
|
||||
spent = svc.spent_24h(owner_kind, owner_id)
|
||||
if limit > 0 and spent >= limit:
|
||||
if svc.quota_exceeded(owner_kind, owner_id, owner_is_admin):
|
||||
limit = svc.daily_limit_for(owner_kind, owner_is_admin)
|
||||
audit.record_system(
|
||||
"ai.quota.exceeded",
|
||||
actor_kind=owner_kind if owner_kind == "guest" else "user",
|
||||
@@ -283,6 +292,12 @@ async def devii_ws(websocket: WebSocket):
|
||||
bool(data.get("visible", True)),
|
||||
bool(data.get("focused", False)),
|
||||
)
|
||||
elif kind == "clientinfo":
|
||||
offset = data.get("offset")
|
||||
session.set_clientinfo(
|
||||
str(data.get("timezone", "") or "").strip(),
|
||||
int(offset) if isinstance(offset, (int, float)) else None,
|
||||
)
|
||||
elif kind in ("avatar_result", "client_result"):
|
||||
session.resolve_query(str(data.get("id", "")), data.get("result"))
|
||||
except WebSocketDisconnect:
|
||||
|
||||
@@ -69,6 +69,12 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "telegram",
|
||||
"title": "Devii on Telegram",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "dashboard",
|
||||
"title": "Your dashboard",
|
||||
@@ -174,6 +180,12 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_COMPONENTS,
|
||||
},
|
||||
{
|
||||
"slug": "component-dp-title",
|
||||
"title": "dp-title",
|
||||
"kind": "prose",
|
||||
"section": SECTION_COMPONENTS,
|
||||
},
|
||||
{
|
||||
"slug": "component-dp-upload",
|
||||
"title": "dp-upload",
|
||||
@@ -321,6 +333,13 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_ADMIN,
|
||||
},
|
||||
{
|
||||
"slug": "telegram-admin",
|
||||
"title": "Telegram bot operations",
|
||||
"kind": "prose",
|
||||
"admin": True,
|
||||
"section": SECTION_ADMIN,
|
||||
},
|
||||
{
|
||||
"slug": "media-moderation",
|
||||
"title": "Media moderation",
|
||||
|
||||
@@ -8,6 +8,7 @@ from devplacepy.routers.profile import (
|
||||
customization,
|
||||
index,
|
||||
notifications,
|
||||
telegram,
|
||||
)
|
||||
from devplacepy.routers.profile.usage import _ai_quota
|
||||
|
||||
@@ -17,5 +18,6 @@ router.include_router(customization.router)
|
||||
router.include_router(notifications.router)
|
||||
router.include_router(ai_correction.router)
|
||||
router.include_router(ai_modifier.router)
|
||||
router.include_router(telegram.router)
|
||||
|
||||
__all__ = ["router", "_ai_quota"]
|
||||
|
||||
@@ -262,6 +262,11 @@ async def profile_page(
|
||||
if is_owner
|
||||
else None
|
||||
)
|
||||
from devplacepy.services.telegram import store as telegram_store
|
||||
|
||||
telegram_paired = (
|
||||
telegram_store.is_paired(profile_user["uid"]) if is_owner else False
|
||||
)
|
||||
profile_is_admin = profile_user.get("role") == "Admin"
|
||||
ai_quota = (
|
||||
_ai_quota(
|
||||
@@ -349,6 +354,7 @@ async def profile_page(
|
||||
"ai_modifier_enabled": ai_modifier_enabled,
|
||||
"ai_modifier_sync": ai_modifier_sync,
|
||||
"ai_modifier_prompt": ai_modifier_prompt,
|
||||
"telegram_paired": telegram_paired,
|
||||
"can_manage_customization": can_manage_customization,
|
||||
"cust_disable_global": customization_prefs["disable_global"],
|
||||
"cust_disable_pagetype": customization_prefs["disable_pagetype"],
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import TelegramPairForm
|
||||
from devplacepy.responses import wants_json
|
||||
from devplacepy.routers.profile._shared import resolve_customization_target
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.telegram import store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/{username}/telegram")
|
||||
async def telegram_pairing(
|
||||
request: Request,
|
||||
username: str,
|
||||
data: Annotated[TelegramPairForm, Depends(json_or_form(TelegramPairForm))],
|
||||
) -> None:
|
||||
target, denied = resolve_customization_target(request, username)
|
||||
if denied is not None:
|
||||
return denied
|
||||
url = f"/profile/{target['username']}"
|
||||
if data.action == "unpair":
|
||||
store.unpair(target["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"telegram.unpair",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
summary=f"unpaired Telegram for {target['username']}",
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
payload = {"ok": True, "paired": False}
|
||||
else:
|
||||
issued = store.issue_code(target["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"telegram.pair.request",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
summary=f"requested a Telegram pairing code for {target['username']}",
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
payload = {
|
||||
"ok": True,
|
||||
"paired": False,
|
||||
"code": issued["code"],
|
||||
"expires_at": issued["expires_at"],
|
||||
"ttl_minutes": issued["ttl_minutes"],
|
||||
}
|
||||
if wants_json(request):
|
||||
return JSONResponse(payload)
|
||||
return RedirectResponse(url=url, status_code=302)
|
||||
@@ -223,6 +223,29 @@ async def project_detail(request: Request, project_slug: str):
|
||||
model=ProjectDetailOut,
|
||||
)
|
||||
|
||||
_ACTIVE_JOB_STATES = (queue.PENDING, queue.RUNNING)
|
||||
|
||||
|
||||
def _active_job(kind: str, owner: tuple[str, str]) -> dict | None:
|
||||
for job in queue.list_jobs(kind=kind, owner=owner):
|
||||
if job.get("status") in _ACTIVE_JOB_STATES:
|
||||
return job
|
||||
return None
|
||||
|
||||
|
||||
def _busy_response(kind: str, job: dict) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"status": 429,
|
||||
"message": f"You already have a {kind} job running. Wait for it to finish.",
|
||||
"uid": job["uid"],
|
||||
}
|
||||
},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_slug}/zip")
|
||||
async def zip_project(request: Request, project_slug: str):
|
||||
project = resolve_by_slug(get_table("projects"), project_slug)
|
||||
@@ -234,6 +257,9 @@ async def zip_project(request: Request, project_slug: str):
|
||||
owner_kind, owner_id = (
|
||||
("user", user["uid"]) if user else ("guest", request.client.host or "anon")
|
||||
)
|
||||
busy = _active_job("zip", (owner_kind, owner_id))
|
||||
if busy:
|
||||
return _busy_response("zip", busy)
|
||||
uid = queue.enqueue(
|
||||
"zip",
|
||||
{"source": {"type": "project_tree", "project_uid": project["uid"], "path": ""}},
|
||||
@@ -266,6 +292,9 @@ async def fork_project(
|
||||
raise not_found("Project not found")
|
||||
if not can_view_project(source, user):
|
||||
raise not_found("Project not found")
|
||||
busy = _active_job("fork", ("user", user["uid"]))
|
||||
if busy:
|
||||
return _busy_response("fork", busy)
|
||||
title = data.title.strip()
|
||||
uid = queue.enqueue(
|
||||
"fork",
|
||||
|
||||
@@ -21,6 +21,14 @@ class ErrorOut(_Out):
|
||||
error: dict
|
||||
|
||||
|
||||
class TelegramPairOut(_Out):
|
||||
ok: bool = True
|
||||
paired: bool = False
|
||||
code: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
ttl_minutes: Optional[int] = None
|
||||
|
||||
|
||||
class ValidationErrorOut(_Out):
|
||||
error: str = "validation"
|
||||
fields: dict = {}
|
||||
@@ -732,6 +740,7 @@ class ProfileOut(_Out):
|
||||
ai_modifier_enabled: bool = False
|
||||
ai_modifier_sync: bool = False
|
||||
ai_modifier_prompt: Optional[str] = None
|
||||
telegram_paired: bool = False
|
||||
can_manage_customization: bool = False
|
||||
cust_disable_global: bool = False
|
||||
cust_disable_pagetype: bool = False
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
@@ -10,8 +9,9 @@ from devplacepy.services.ai_context import build_context
|
||||
from devplacepy.services.background import background
|
||||
from devplacepy.services.correction import (
|
||||
CORRECTABLE_FIELDS,
|
||||
PENDING_SCOPE_KEY,
|
||||
gateway_complete,
|
||||
new_usage_totals,
|
||||
schedule_pending,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,35 +60,13 @@ def schedule_modification(
|
||||
return
|
||||
prompt = (user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT).strip()
|
||||
user_uid = user.get("uid") or ""
|
||||
if user.get("ai_modifier_sync") and _run_inline_awaited(
|
||||
api_key, prompt, table, uid, user_uid, request
|
||||
if user.get("ai_modifier_sync") and schedule_pending(
|
||||
_run_modification, request, api_key, prompt, table, uid, user_uid
|
||||
):
|
||||
return
|
||||
background.submit(_run_modification, api_key, prompt, table, uid, user_uid)
|
||||
|
||||
|
||||
def _run_inline_awaited(
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
table: str,
|
||||
uid: str,
|
||||
user_uid: str,
|
||||
request: object,
|
||||
) -> bool:
|
||||
scope = getattr(request, "scope", None)
|
||||
if scope is None:
|
||||
return False
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return False
|
||||
future = loop.run_in_executor(
|
||||
None, _run_modification, api_key, prompt, table, uid, user_uid
|
||||
)
|
||||
scope.setdefault(PENDING_SCOPE_KEY, []).append(future)
|
||||
return True
|
||||
|
||||
|
||||
def _run_modification(
|
||||
api_key: str, prompt: str, table: str, uid: str, user_uid: str
|
||||
) -> None:
|
||||
@@ -99,15 +77,7 @@ def _run_modification(
|
||||
if not row:
|
||||
return
|
||||
updates: dict = {}
|
||||
totals = {
|
||||
"calls": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"upstream_latency_ms": 0.0,
|
||||
"total_latency_ms": 0.0,
|
||||
}
|
||||
totals = new_usage_totals()
|
||||
context = None
|
||||
for field in fields:
|
||||
original = row.get(field) or ""
|
||||
|
||||
@@ -34,7 +34,9 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
|
||||
"ai": "ai",
|
||||
"database": "database",
|
||||
"pubsub": "pubsub",
|
||||
"telegram": "telegram",
|
||||
"devii": "devii",
|
||||
"email": "email",
|
||||
"cli": "cli",
|
||||
"reward": "reward",
|
||||
"security": "security",
|
||||
|
||||
@@ -336,16 +336,22 @@ class DevPlaceBot:
|
||||
return ""
|
||||
return (key or "").strip()
|
||||
|
||||
async def _adopt_account_api_key(self) -> None:
|
||||
async def _adopt_account_api_key(self) -> bool:
|
||||
if self.state.account_api_key:
|
||||
return
|
||||
key = await self._fetch_account_api_key()
|
||||
if not key:
|
||||
return
|
||||
self.state.account_api_key = key
|
||||
self.llm.api_key = key
|
||||
self._save()
|
||||
self._log("Adopted account API key for gateway calls")
|
||||
self.llm.api_key = self.state.account_api_key
|
||||
return True
|
||||
for attempt in range(5):
|
||||
key = await self._fetch_account_api_key()
|
||||
if key:
|
||||
self.state.account_api_key = key
|
||||
self.llm.api_key = key
|
||||
self._save()
|
||||
self._log("Adopted account API key for gateway calls")
|
||||
return True
|
||||
if attempt < 4:
|
||||
await asyncio.sleep(2)
|
||||
self._log("Account API key unavailable after retries")
|
||||
return False
|
||||
|
||||
async def _read_like_human(self, min_s: float = 3.0, max_s: float = 90.0) -> None:
|
||||
if not self.state.reading_speed_wpm:
|
||||
@@ -2833,7 +2839,11 @@ class DevPlaceBot:
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
await self._adopt_account_api_key()
|
||||
if not await self._adopt_account_api_key():
|
||||
self._log("No own API key yet; ending session to avoid shared-key billing")
|
||||
await b.close()
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
self._bind_monitor()
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ BASE_URL_DEFAULT = "https://pravda.education"
|
||||
API_URL_DEFAULT = INTERNAL_GATEWAY_URL
|
||||
NEWS_API_DEFAULT = "https://news.app.molodetz.nl/api"
|
||||
MODEL_DEFAULT = INTERNAL_MODEL
|
||||
INPUT_COST_PER_1M_DEFAULT = 0.27
|
||||
OUTPUT_COST_PER_1M_DEFAULT = 1.10
|
||||
INPUT_COST_PER_1M_DEFAULT = 0.14
|
||||
OUTPUT_COST_PER_1M_DEFAULT = 0.28
|
||||
|
||||
COST_WINDOW_SECONDS = 600
|
||||
COST_WARMUP_SECONDS = 120
|
||||
|
||||
@@ -9,15 +9,16 @@ from devplacepy.services.bot.config import HANDLE_SUFFIX_WORDS
|
||||
MIN_HANDLE_LEN = 3
|
||||
MAX_HANDLE_LEN = 20
|
||||
MIN_SEED_TOKEN_LEN = 3
|
||||
LEET_PROBABILITY = 0.35
|
||||
LEET_CHAR_PROBABILITY = 0.5
|
||||
NUMBER_SUFFIX_PROBABILITY = 0.30
|
||||
SEPARATOR_PROBABILITY = 0.30
|
||||
CAPITALIZE_PROBABILITY = 0.22
|
||||
ALLCAPS_PROBABILITY = 0.08
|
||||
LEET_CHAR_PROBABILITY = 0.35
|
||||
NUMBER_SUFFIX_PROBABILITY = 0.26
|
||||
DECORATION_STYLES = ["plain", "leet", "vowel"]
|
||||
DECORATION_WEIGHTS = [64, 22, 14]
|
||||
CAPITALIZE_PROBABILITY = 0.20
|
||||
ALLCAPS_PROBABILITY = 0.06
|
||||
ALLCAPS_MAX_LEN = 8
|
||||
PAD_NUMBER_FLOOR = 10
|
||||
PAD_NUMBER_CEIL = 9999
|
||||
VOWELS = "aeiou"
|
||||
|
||||
LEET_MAP = {
|
||||
"a": "4",
|
||||
@@ -39,33 +40,60 @@ TECH_NOUNS = [
|
||||
"mutex", "syntax", "binary", "octet", "raster", "shader", "codec",
|
||||
"vertex", "matrix", "scalar", "payload", "opcode", "runtime", "compiler",
|
||||
"parser", "lexer", "sandbox", "firmware", "bytecode", "checksum",
|
||||
"entropy", "signal", "overflow", "nibbler", "cursor", "patch",
|
||||
"entropy", "signal", "overflow", "nibbler", "cursor", "patch", "semaphore",
|
||||
"spinlock", "deadlock", "webhook", "endpoint", "gateway", "registry",
|
||||
"pipeline", "tensor", "gradient", "cluster", "shard", "replica", "schema",
|
||||
"commit", "branch", "rebase", "stash", "blob", "symlink", "inode", "mmap",
|
||||
"syscall", "interrupt", "watchdog", "heartbeat", "beacon", "subnet",
|
||||
"datagram", "loopback", "nullref", "dangle", "leak", "trace", "kilobyte",
|
||||
"qubit", "monad", "closure", "thunk", "fiber", "coro", "atom",
|
||||
]
|
||||
|
||||
HANDLE_ADJECTIVES = [
|
||||
"dark", "mad", "lazy", "quantum", "cyber", "neon", "ghost", "hyper",
|
||||
"ultra", "neo", "retro", "raw", "zero", "async", "blind", "silent",
|
||||
"atomic", "fatal", "rogue", "idle", "stale", "dirty", "lucid", "prime",
|
||||
"hex", "cold", "lone", "wired", "broken", "feral",
|
||||
"hex", "cold", "lone", "wired", "broken", "feral", "stray", "numb",
|
||||
"vivid", "molten", "frozen", "hollow", "jagged", "static", "volatile",
|
||||
"nomadic", "unsigned", "verbose", "terse", "nocturnal", "caffeinated",
|
||||
"deprecated", "headless", "stateless", "immutable", "recursive",
|
||||
]
|
||||
|
||||
HANDLE_CREATURES = [
|
||||
"fox", "wolf", "raven", "owl", "octopus", "gecko", "mantis", "viper",
|
||||
"lynx", "moth", "hydra", "kraken", "phoenix", "dragon", "falcon",
|
||||
"panther", "cobra", "badger", "otter", "sloth", "hornet", "beetle",
|
||||
"lemur", "narwhal", "basilisk",
|
||||
"lemur", "narwhal", "basilisk", "magpie", "heron", "ferret", "weasel",
|
||||
"jackal", "marten", "axolotl", "tardigrade", "wyvern", "griffin",
|
||||
"salamander", "newt", "stoat", "crow", "shrike", "ibis",
|
||||
]
|
||||
|
||||
HANDLE_PREFIXES = ["the", "mr", "dr", "lord", "captain", "uncle", "sir", "prof", "agent"]
|
||||
HANDLE_PREFIXES = [
|
||||
"the", "mr", "dr", "lord", "captain", "uncle", "sir", "prof", "agent",
|
||||
"old", "lil", "big", "saint", "baron", "comrade", "major",
|
||||
]
|
||||
|
||||
NUMBER_SUFFIXES = ["42", "1337", "404", "64", "99", "007", "23", "88", "256", "512", "101", "777"]
|
||||
ROLE_WORDS = [
|
||||
"dev", "coder", "hacker", "ninja", "guru", "wizard", "smith", "monk",
|
||||
"nomad", "ghost", "pilot", "surgeon", "gremlin", "goblin", "wrangler",
|
||||
"jockey", "slinger", "herder", "whisperer", "monkey", "sage", "druid",
|
||||
"ronin", "samurai", "pirate", "tinkerer", "alchemist", "janitor",
|
||||
"plumber", "gardener", "scribe", "warden", "ranger", "miner", "forge",
|
||||
]
|
||||
|
||||
SEPARATORS = ["_", "-"]
|
||||
NUMBER_SUFFIXES = [
|
||||
"42", "1337", "404", "64", "99", "007", "23", "88", "256", "512", "101",
|
||||
"777", "13", "21", "69", "420", "127", "255", "80", "443", "3000", "8080",
|
||||
"2k", "9000", "65535", "16", "32", "128",
|
||||
]
|
||||
|
||||
VERSION_TAGS = [
|
||||
"v2", "v3", "x64", "x86", "js", "py", "sh", "io", "fn", "db", "ai", "os",
|
||||
"hq", "exe", "dev", "rs", "go", "cpp", "ml",
|
||||
]
|
||||
|
||||
def _join(rng: random.Random, parts: list[str]) -> str:
|
||||
separator = rng.choice(SEPARATORS) if rng.random() < SEPARATOR_PROBABILITY else ""
|
||||
return separator.join(parts)
|
||||
JOIN_STYLES = ["none", "under", "hyphen", "camel"]
|
||||
JOIN_WEIGHTS = [46, 19, 12, 23]
|
||||
|
||||
|
||||
def _seed_tokens(interests: list[str]) -> list[str]:
|
||||
@@ -77,26 +105,81 @@ def _seed_tokens(interests: list[str]) -> list[str]:
|
||||
return tokens
|
||||
|
||||
|
||||
def _build_core(rng: random.Random, interests: list[str]) -> str:
|
||||
seeds = _seed_tokens(interests)
|
||||
builders = [
|
||||
lambda: rng.choice(TECH_NOUNS),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(TECH_NOUNS)]),
|
||||
lambda: _join(rng, [rng.choice(TECH_NOUNS), rng.choice(HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_PREFIXES), rng.choice(TECH_NOUNS + HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(TECH_NOUNS), rng.choice(HANDLE_SUFFIX_WORDS)]),
|
||||
def _seed_word(rng: random.Random, seeds: list[str]) -> str:
|
||||
return rng.choice(seeds) if seeds else rng.choice(TECH_NOUNS)
|
||||
|
||||
|
||||
def _tokens(rng: random.Random, seeds: list[str]) -> list[str]:
|
||||
noun = lambda: rng.choice(TECH_NOUNS)
|
||||
adjective = lambda: rng.choice(HANDLE_ADJECTIVES)
|
||||
creature = lambda: rng.choice(HANDLE_CREATURES)
|
||||
role = lambda: rng.choice(ROLE_WORDS)
|
||||
prefix = lambda: rng.choice(HANDLE_PREFIXES)
|
||||
suffix = lambda: rng.choice(HANDLE_SUFFIX_WORDS)
|
||||
seed = lambda: _seed_word(rng, seeds)
|
||||
plans = [
|
||||
(7, lambda: [noun()]),
|
||||
(3, lambda: [role()]),
|
||||
(8, lambda: [adjective(), noun()]),
|
||||
(6, lambda: [noun(), creature()]),
|
||||
(5, lambda: [adjective(), creature()]),
|
||||
(5, lambda: [noun(), role()]),
|
||||
(4, lambda: [adjective(), role()]),
|
||||
(4, lambda: [creature(), role()]),
|
||||
(4, lambda: [prefix(), noun()]),
|
||||
(3, lambda: [prefix(), creature()]),
|
||||
(4, lambda: [role(), noun()]),
|
||||
(3, lambda: [noun(), suffix()]),
|
||||
(3, lambda: [adjective(), suffix()]),
|
||||
(3, lambda: [noun(), noun()]),
|
||||
(2, lambda: [adjective(), noun(), creature()]),
|
||||
(2, lambda: [prefix(), adjective(), noun()]),
|
||||
(6, lambda: [seed(), role()]),
|
||||
(5, lambda: [adjective(), seed()]),
|
||||
(4, lambda: [seed(), noun()]),
|
||||
(3, lambda: [seed()]),
|
||||
]
|
||||
weights = [3, 4, 3, 2, 2, 2]
|
||||
if seeds:
|
||||
builders.append(
|
||||
lambda: _join(rng, [rng.choice(seeds), rng.choice(HANDLE_SUFFIX_WORDS + TECH_NOUNS)])
|
||||
)
|
||||
builders.append(lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(seeds)]))
|
||||
weights += [5, 3]
|
||||
weights = [weight for weight, _ in plans]
|
||||
builders = [builder for _, builder in plans]
|
||||
return rng.choices(builders, weights=weights)[0]()
|
||||
|
||||
|
||||
def _dedupe(rng: random.Random, tokens: list[str]) -> list[str]:
|
||||
result: list[str] = []
|
||||
for token in tokens:
|
||||
if result and result[-1] == token:
|
||||
token = rng.choice(TECH_NOUNS)
|
||||
result.append(token)
|
||||
return result
|
||||
|
||||
|
||||
def _fuse(rng: random.Random, tokens: list[str]) -> str:
|
||||
if len(tokens) == 1:
|
||||
return tokens[0]
|
||||
style = rng.choices(JOIN_STYLES, weights=JOIN_WEIGHTS)[0]
|
||||
if style == "camel":
|
||||
head, *rest = tokens
|
||||
return head + "".join(part[:1].upper() + part[1:] for part in rest)
|
||||
separator = {"none": "", "under": "_", "hyphen": "-"}[style]
|
||||
return separator.join(tokens)
|
||||
|
||||
|
||||
def _drop_vowels(rng: random.Random, text: str) -> str:
|
||||
kept = [text[0]]
|
||||
for char in text[1:]:
|
||||
if char in VOWELS and rng.random() < 0.55:
|
||||
continue
|
||||
kept.append(char)
|
||||
result = "".join(kept)
|
||||
return result if len(result) >= MIN_HANDLE_LEN else text
|
||||
|
||||
|
||||
def _maybe_tag(rng: random.Random, core: str) -> str:
|
||||
if rng.random() >= NUMBER_SUFFIX_PROBABILITY:
|
||||
return core
|
||||
return f"{core}{rng.choice(NUMBER_SUFFIXES + VERSION_TAGS)}"
|
||||
|
||||
|
||||
def apply_leet(text: str, rng: random.Random) -> str:
|
||||
chars: list[str] = []
|
||||
for char in text:
|
||||
@@ -108,7 +191,7 @@ def apply_leet(text: str, rng: random.Random) -> str:
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def _apply_case(text: str, rng: random.Random) -> str:
|
||||
def _apply_case(rng: random.Random, text: str) -> str:
|
||||
roll = rng.random()
|
||||
if roll < ALLCAPS_PROBABILITY and len(text) <= ALLCAPS_MAX_LEN:
|
||||
return text.upper()
|
||||
@@ -125,13 +208,17 @@ def sanitize_handle(raw: str) -> str:
|
||||
|
||||
def make_handle(interests: Optional[list[str]] = None, rng: Optional[random.Random] = None) -> str:
|
||||
generator = rng or random
|
||||
core = _build_core(generator, interests or [])
|
||||
if generator.random() < LEET_PROBABILITY:
|
||||
seeds = _seed_tokens(interests or [])
|
||||
core = _fuse(generator, _dedupe(generator, _tokens(generator, seeds)))
|
||||
decoration = generator.choices(DECORATION_STYLES, weights=DECORATION_WEIGHTS)[0]
|
||||
if decoration == "vowel" and core.isalpha() and core.islower() and len(core) > 6:
|
||||
core = _drop_vowels(generator, core)
|
||||
elif decoration == "leet":
|
||||
core = apply_leet(core, generator)
|
||||
if generator.random() < NUMBER_SUFFIX_PROBABILITY:
|
||||
core = f"{core}{generator.choice(NUMBER_SUFFIXES)}"
|
||||
if decoration != "leet":
|
||||
core = _maybe_tag(generator, core)
|
||||
handle = sanitize_handle(core)
|
||||
if len(handle) < MIN_HANDLE_LEN:
|
||||
handle = f"{handle}{generator.randint(PAD_NUMBER_FLOOR, PAD_NUMBER_CEIL)}"
|
||||
handle = _apply_case(handle, generator)
|
||||
handle = _apply_case(generator, handle)
|
||||
return handle or f"dev{generator.randint(PAD_NUMBER_FLOOR, PAD_NUMBER_CEIL)}"
|
||||
|
||||
@@ -19,6 +19,7 @@ from devplacepy.services.bot.config import (
|
||||
TRIVIAL_GIST_TERMS,
|
||||
)
|
||||
from devplacepy.services.bot.handles import MAX_HANDLE_LEN, sanitize_handle
|
||||
from devplacepy.services.openai_gateway.usage import parse_usage_headers
|
||||
|
||||
HANDLE_CANDIDATE_TARGET = 8
|
||||
|
||||
@@ -74,11 +75,8 @@ class LLMClient:
|
||||
raw = resp.content
|
||||
logger.info("LLM <<< %s", raw[:2000].decode(errors="replace"))
|
||||
result = resp.json()
|
||||
usage = result.get("usage", {})
|
||||
in_tokens = usage.get("prompt_tokens", 0)
|
||||
out_tokens = usage.get("completion_tokens", 0)
|
||||
cost = (in_tokens * self.input_cost_per_1m / 1_000_000) + (
|
||||
out_tokens * self.output_cost_per_1m / 1_000_000
|
||||
in_tokens, out_tokens, cost = self._account_usage(
|
||||
resp.headers, result.get("usage", {})
|
||||
)
|
||||
self.total_cost += cost
|
||||
self.total_calls += 1
|
||||
@@ -96,6 +94,21 @@ class LLMClient:
|
||||
time.sleep(2**attempt)
|
||||
return ""
|
||||
|
||||
def _account_usage(self, response_headers, body_usage: dict) -> tuple[int, int, float]:
|
||||
parsed = parse_usage_headers(response_headers)
|
||||
if parsed is not None:
|
||||
in_tokens = parsed["prompt_tokens"]
|
||||
out_tokens = parsed["completion_tokens"]
|
||||
if not out_tokens and parsed["total_tokens"] > in_tokens:
|
||||
out_tokens = parsed["total_tokens"] - in_tokens
|
||||
return in_tokens, out_tokens, parsed["cost_usd"]
|
||||
in_tokens = body_usage.get("prompt_tokens", 0)
|
||||
out_tokens = body_usage.get("completion_tokens", 0)
|
||||
cost = (in_tokens * self.input_cost_per_1m / 1_000_000) + (
|
||||
out_tokens * self.output_cost_per_1m / 1_000_000
|
||||
)
|
||||
return in_tokens, out_tokens, cost
|
||||
|
||||
def _call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
|
||||
return self.clean(self._raw_call(system, prompt, temperature))
|
||||
|
||||
@@ -496,12 +509,20 @@ class LLMClient:
|
||||
"You invent online handles for a developer signing up to a programmer "
|
||||
"community in the style of devRant or Hacker News. The handles read like a "
|
||||
"real nerd picked them, never like a person's full name. Lean on programming "
|
||||
"and hacker culture: tech nouns (null, kernel, segfault, daemon), occasional "
|
||||
"leetspeak (c0d3r, h4x), an adjective plus noun, a creature, or a short word "
|
||||
f"with a number. Lowercase mostly, {MAX_HANDLE_LEN} characters or fewer, only "
|
||||
"letters, digits, underscores and hyphens, no spaces and no dots. Return ONLY "
|
||||
f'a JSON object {{"handles": ["...", "..."]}} with {HANDLE_CANDIDATE_TARGET} '
|
||||
"distinct handles, no prose, no markdown fences. No em dashes."
|
||||
"and hacker culture, but make every handle in the list a DIFFERENT shape so "
|
||||
"they never feel formulaic. Mix these forms across the list: a single fused "
|
||||
"word (segfault, mutexlord, kernelpanic); two words joined with no separator "
|
||||
"(darkbyte, neonferret); camelCase (nullPointer, byteFox); an underscore or a "
|
||||
"hyphen but NOT on most of them (lazy_daemon, cold-stack); leetspeak "
|
||||
"(n0_scalar, c0d3r, h4xwolf); a word plus a number, port, or version tag "
|
||||
"(void404, daemon1337, byte_v2, heap8080); dropped vowels (krnlpnc, bffr, "
|
||||
"mtxguru); and an unexpected creature, role, or prefix (dr_segfault, "
|
||||
"raven_smith, axolotl_dev). Vary the length from 4 to 18. Lowercase mostly "
|
||||
"with the odd capital. Do NOT make them all the 'word_word' underscore "
|
||||
f"pattern. {MAX_HANDLE_LEN} characters or fewer, only letters, digits, "
|
||||
"underscores and hyphens, no spaces and no dots. Return ONLY a JSON object "
|
||||
f'{{"handles": ["...", "..."]}} with {HANDLE_CANDIDATE_TARGET} distinct '
|
||||
"handles, each a distinct shape, no prose, no markdown fences. No em dashes."
|
||||
)
|
||||
prompt = (
|
||||
f"Archetype: {archetype}\n"
|
||||
|
||||
@@ -98,7 +98,7 @@ class BotsService(BaseService):
|
||||
type="float",
|
||||
default=config.INPUT_COST_PER_1M_DEFAULT,
|
||||
minimum=0,
|
||||
help="Used to compute live spend per bot and across the fleet.",
|
||||
help="Fallback pricing, used only when the LLM endpoint returns no gateway cost headers. The internal gateway reports authoritative cache-aware cost per call.",
|
||||
group="LLM",
|
||||
),
|
||||
ConfigField(
|
||||
@@ -107,7 +107,7 @@ class BotsService(BaseService):
|
||||
type="float",
|
||||
default=config.OUTPUT_COST_PER_1M_DEFAULT,
|
||||
minimum=0,
|
||||
help="Used to compute live spend per bot and across the fleet.",
|
||||
help="Fallback pricing, used only when the LLM endpoint returns no gateway cost headers. The internal gateway reports authoritative cache-aware cost per call.",
|
||||
group="LLM",
|
||||
),
|
||||
ConfigField(
|
||||
|
||||
@@ -13,6 +13,7 @@ from devplacepy.config import (
|
||||
)
|
||||
from devplacepy.database import add_correction_usage, get_table
|
||||
from devplacepy.services.background import background
|
||||
from devplacepy.services.openai_gateway.usage import parse_usage_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,26 +32,25 @@ PENDING_SCOPE_KEY = "devplace_pending_corrections"
|
||||
|
||||
|
||||
def _usage_from_headers(response_headers) -> dict:
|
||||
def _int(name: str) -> int:
|
||||
try:
|
||||
return int(response_headers.get(name) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def _float(name: str) -> float:
|
||||
try:
|
||||
return float(response_headers.get(name) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
parsed = parse_usage_headers(response_headers)
|
||||
if parsed is None:
|
||||
return {
|
||||
"calls": 1,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"upstream_latency_ms": 0.0,
|
||||
"total_latency_ms": 0.0,
|
||||
}
|
||||
return {
|
||||
"calls": 1,
|
||||
"prompt_tokens": _int("X-Gateway-Prompt-Tokens"),
|
||||
"completion_tokens": _int("X-Gateway-Completion-Tokens"),
|
||||
"total_tokens": _int("X-Gateway-Total-Tokens"),
|
||||
"cost_usd": _float("X-Gateway-Cost-USD"),
|
||||
"upstream_latency_ms": _float("X-Gateway-Upstream-Latency-Ms"),
|
||||
"total_latency_ms": _float("X-Gateway-Total-Latency-Ms"),
|
||||
"calls": parsed["calls"],
|
||||
"prompt_tokens": parsed["prompt_tokens"],
|
||||
"completion_tokens": parsed["completion_tokens"],
|
||||
"total_tokens": parsed["total_tokens"],
|
||||
"cost_usd": parsed["cost_usd"],
|
||||
"upstream_latency_ms": parsed["upstream_latency_ms"],
|
||||
"total_latency_ms": parsed["total_latency_ms"],
|
||||
}
|
||||
|
||||
|
||||
@@ -123,21 +123,14 @@ def schedule_correction(
|
||||
return
|
||||
prompt = (user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT).strip()
|
||||
user_uid = user.get("uid") or ""
|
||||
if user.get("ai_correction_sync") and _run_inline_awaited(
|
||||
api_key, prompt, table, uid, user_uid, request
|
||||
if user.get("ai_correction_sync") and schedule_pending(
|
||||
_run_correction, request, api_key, prompt, table, uid, user_uid
|
||||
):
|
||||
return
|
||||
background.submit(_run_correction, api_key, prompt, table, uid, user_uid)
|
||||
|
||||
|
||||
def _run_inline_awaited(
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
table: str,
|
||||
uid: str,
|
||||
user_uid: str,
|
||||
request: object,
|
||||
) -> bool:
|
||||
def schedule_pending(fn, request: object, *args) -> bool:
|
||||
scope = getattr(request, "scope", None)
|
||||
if scope is None:
|
||||
return False
|
||||
@@ -145,13 +138,23 @@ def _run_inline_awaited(
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return False
|
||||
future = loop.run_in_executor(
|
||||
None, _run_correction, api_key, prompt, table, uid, user_uid
|
||||
)
|
||||
future = loop.run_in_executor(None, fn, *args)
|
||||
scope.setdefault(PENDING_SCOPE_KEY, []).append(future)
|
||||
return True
|
||||
|
||||
|
||||
def new_usage_totals() -> dict:
|
||||
return {
|
||||
"calls": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"upstream_latency_ms": 0.0,
|
||||
"total_latency_ms": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _run_correction(
|
||||
api_key: str, prompt: str, table: str, uid: str, user_uid: str
|
||||
) -> None:
|
||||
@@ -162,15 +165,7 @@ def _run_correction(
|
||||
if not row:
|
||||
return
|
||||
updates: dict = {}
|
||||
totals = {
|
||||
"calls": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"upstream_latency_ms": 0.0,
|
||||
"total_latency_ms": 0.0,
|
||||
}
|
||||
totals = new_usage_totals()
|
||||
for field in fields:
|
||||
original = row.get(field) or ""
|
||||
if not original.strip():
|
||||
|
||||
@@ -9,9 +9,8 @@ from devplacepy.database import (
|
||||
SOFT_DELETE_TABLES,
|
||||
db,
|
||||
get_setting,
|
||||
internal_gateway_key,
|
||||
)
|
||||
from devplacepy.utils import _user_from_api_key, get_current_user, is_admin
|
||||
from devplacepy.utils import _user_from_api_key, get_current_user, is_primary_admin
|
||||
|
||||
TABLE_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
DEFAULT_DENY = {"sessions", "password_resets", "cache_state"}
|
||||
@@ -32,10 +31,6 @@ class Caller:
|
||||
username: str
|
||||
api_key: str
|
||||
|
||||
@property
|
||||
def is_internal(self) -> bool:
|
||||
return self.kind == "internal"
|
||||
|
||||
|
||||
def deny_tables() -> set[str]:
|
||||
raw = get_setting("dbapi_deny_tables", "")
|
||||
@@ -55,7 +50,7 @@ def _bearer_key(request) -> str:
|
||||
|
||||
def caller_for(request) -> Caller | None:
|
||||
user = get_current_user(request)
|
||||
if user and is_admin(user):
|
||||
if user and is_primary_admin(user):
|
||||
return Caller(
|
||||
kind="admin",
|
||||
uid=user["uid"],
|
||||
@@ -63,11 +58,9 @@ def caller_for(request) -> Caller | None:
|
||||
api_key=user.get("api_key", "") or "",
|
||||
)
|
||||
key = _bearer_key(request)
|
||||
if key and key == internal_gateway_key():
|
||||
return Caller(kind="internal", uid="internal", username="internal", api_key=key)
|
||||
if key:
|
||||
keyed = _user_from_api_key(key)
|
||||
if keyed and is_admin(keyed):
|
||||
if keyed and is_primary_admin(keyed):
|
||||
return Caller(
|
||||
kind="admin",
|
||||
uid=keyed["uid"],
|
||||
@@ -80,7 +73,7 @@ def caller_for(request) -> Caller | None:
|
||||
def require_caller(request) -> Caller:
|
||||
caller = caller_for(request)
|
||||
if caller is None:
|
||||
raise DbApiDenied("Administrator or internal-key access required.")
|
||||
raise DbApiDenied("Primary-administrator access required.")
|
||||
return caller
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import EmailClient
|
||||
from .config import EmailAccount
|
||||
from .errors import EmailError
|
||||
|
||||
__all__ = ["EmailClient", "EmailAccount", "EmailError"]
|
||||
@@ -0,0 +1,355 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import email
|
||||
import imaplib
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from contextlib import contextmanager
|
||||
from email.message import EmailMessage
|
||||
from email.policy import default as default_policy
|
||||
from email.utils import formataddr, getaddresses, parsedate_to_datetime
|
||||
from typing import Any, Iterator
|
||||
|
||||
from devplacepy.net_guard import BlockedAddressError, guard_public_host_sync
|
||||
|
||||
from .config import EmailAccount
|
||||
from .errors import EmailError
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
MAX_BODY_CHARS = 80_000
|
||||
HEADER_FIELDS = "(FROM TO CC SUBJECT DATE MESSAGE-ID)"
|
||||
SUMMARY_ITEMS = f"(UID FLAGS BODY.PEEK[HEADER.FIELDS {HEADER_FIELDS}])"
|
||||
_UID_PATTERN = re.compile(rb"UID (\d+)")
|
||||
|
||||
|
||||
def imap_quote(value: str) -> str:
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def _header_addresses(value: str) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [formataddr(pair) for pair in getaddresses([value]) if pair[1]]
|
||||
|
||||
|
||||
def _decode_date(raw: str) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return parsedate_to_datetime(raw).isoformat()
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
|
||||
|
||||
class EmailClient:
|
||||
def __init__(
|
||||
self, account: EmailAccount, timeout: float = DEFAULT_TIMEOUT_SECONDS
|
||||
) -> None:
|
||||
self.account = account
|
||||
self.timeout = timeout
|
||||
|
||||
@contextmanager
|
||||
def _imap(self) -> Iterator[imaplib.IMAP4]:
|
||||
account = self.account
|
||||
if not account.imap_host:
|
||||
raise EmailError("This account has no IMAP host configured.", kind="config")
|
||||
try:
|
||||
guard_public_host_sync(account.imap_host)
|
||||
except BlockedAddressError as exc:
|
||||
raise EmailError(str(exc), kind="blocked") from exc
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
if account.imap_ssl:
|
||||
conn: imaplib.IMAP4 = imaplib.IMAP4_SSL(
|
||||
account.imap_host,
|
||||
account.imap_port,
|
||||
ssl_context=context,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
else:
|
||||
conn = imaplib.IMAP4(
|
||||
account.imap_host, account.imap_port, timeout=self.timeout
|
||||
)
|
||||
if account.imap_starttls:
|
||||
conn.starttls(context)
|
||||
conn.login(account.username, account.password)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise EmailError(f"IMAP connection failed: {exc}", kind="connect") from exc
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except (OSError, imaplib.IMAP4.error):
|
||||
pass
|
||||
|
||||
def _select(self, conn: imaplib.IMAP4, folder: str, readonly: bool) -> None:
|
||||
typ, data = conn.select(imap_quote(folder), readonly=readonly)
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Cannot open folder '{folder}': {_first(data)}")
|
||||
|
||||
def _uid_search(self, conn: imaplib.IMAP4, criteria: list[str]) -> list[str]:
|
||||
if not criteria:
|
||||
typ, data = conn.uid("SEARCH", None, "ALL")
|
||||
else:
|
||||
args: list[Any] = [b"CHARSET", b"UTF-8"]
|
||||
args.extend(token.encode("utf-8") for token in criteria)
|
||||
typ, data = conn.uid("SEARCH", *args)
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Search failed: {_first(data)}")
|
||||
if not data or not data[0]:
|
||||
return []
|
||||
return [chunk.decode() for chunk in data[0].split()]
|
||||
|
||||
def list_folders(self) -> list[dict[str, Any]]:
|
||||
with self._imap() as conn:
|
||||
typ, data = conn.list()
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Cannot list folders: {_first(data)}")
|
||||
folders: list[dict[str, Any]] = []
|
||||
for entry in data or []:
|
||||
if entry is None:
|
||||
continue
|
||||
line = entry.decode("utf-8", "replace") if isinstance(entry, bytes) else str(entry)
|
||||
match = re.match(r"\((?P<flags>[^)]*)\) \"?(?P<delim>[^\"]*)\"? (?P<name>.+)", line)
|
||||
if not match:
|
||||
continue
|
||||
name = match.group("name").strip().strip('"')
|
||||
folders.append({"name": name, "flags": match.group("flags").split()})
|
||||
return folders
|
||||
|
||||
def list_messages(
|
||||
self,
|
||||
folder: str,
|
||||
criteria: list[str],
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> dict[str, Any]:
|
||||
with self._imap() as conn:
|
||||
self._select(conn, folder, readonly=True)
|
||||
uids = self._uid_search(conn, criteria)
|
||||
uids.reverse()
|
||||
total = len(uids)
|
||||
window = uids[offset : offset + limit]
|
||||
messages = self._fetch_summaries(conn, window)
|
||||
return {
|
||||
"folder": folder,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"count": len(messages),
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
def _fetch_summaries(
|
||||
self, conn: imaplib.IMAP4, uids: list[str]
|
||||
) -> list[dict[str, Any]]:
|
||||
if not uids:
|
||||
return []
|
||||
typ, data = conn.uid("FETCH", ",".join(uids), SUMMARY_ITEMS)
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Fetch failed: {_first(data)}")
|
||||
order = {uid: index for index, uid in enumerate(uids)}
|
||||
summaries: list[dict[str, Any]] = []
|
||||
for part in data or []:
|
||||
if not isinstance(part, tuple) or len(part) < 2:
|
||||
continue
|
||||
descriptor, header_bytes = part[0], part[1]
|
||||
uid_match = _UID_PATTERN.search(descriptor or b"")
|
||||
if not uid_match:
|
||||
continue
|
||||
uid = uid_match.group(1).decode()
|
||||
flags = [flag.decode() if isinstance(flag, bytes) else str(flag) for flag in imaplib.ParseFlags(descriptor)]
|
||||
headers = email.message_from_bytes(header_bytes or b"", policy=default_policy)
|
||||
summaries.append(
|
||||
{
|
||||
"uid": uid,
|
||||
"flags": flags,
|
||||
"seen": "\\Seen" in flags,
|
||||
"from": str(headers.get("From", "")),
|
||||
"to": _header_addresses(str(headers.get("To", ""))),
|
||||
"subject": str(headers.get("Subject", "")),
|
||||
"date": _decode_date(str(headers.get("Date", ""))),
|
||||
"message_id": str(headers.get("Message-ID", "")),
|
||||
}
|
||||
)
|
||||
summaries.sort(key=lambda item: order.get(item["uid"], 0))
|
||||
return summaries
|
||||
|
||||
def read_message(self, folder: str, uid: str) -> dict[str, Any]:
|
||||
with self._imap() as conn:
|
||||
self._select(conn, folder, readonly=True)
|
||||
typ, data = conn.uid("FETCH", uid, "(FLAGS BODY.PEEK[])")
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Fetch failed: {_first(data)}")
|
||||
raw = _first_payload(data)
|
||||
if raw is None:
|
||||
raise EmailError(f"Message {uid} not found in '{folder}'.", kind="not_found")
|
||||
flags = [flag.decode() if isinstance(flag, bytes) else str(flag) for flag in imaplib.ParseFlags(_first_descriptor(data) or b"")]
|
||||
message = email.message_from_bytes(raw, policy=default_policy)
|
||||
return self._render_message(uid, folder, flags, message)
|
||||
|
||||
def _render_message(
|
||||
self, uid: str, folder: str, flags: list[str], message: Any
|
||||
) -> dict[str, Any]:
|
||||
text_part = message.get_body(preferencelist=("plain",))
|
||||
html_part = message.get_body(preferencelist=("html",))
|
||||
attachments: list[dict[str, Any]] = []
|
||||
for part in message.iter_attachments():
|
||||
payload = part.get_content()
|
||||
size = len(payload) if isinstance(payload, (bytes, str)) else 0
|
||||
attachments.append(
|
||||
{
|
||||
"filename": part.get_filename() or "",
|
||||
"content_type": part.get_content_type(),
|
||||
"size": size,
|
||||
}
|
||||
)
|
||||
body = text_part.get_content().strip() if text_part else ""
|
||||
html = html_part.get_content().strip() if html_part else ""
|
||||
return {
|
||||
"uid": uid,
|
||||
"folder": folder,
|
||||
"flags": flags,
|
||||
"from": str(message.get("From", "")),
|
||||
"to": _header_addresses(str(message.get("To", ""))),
|
||||
"cc": _header_addresses(str(message.get("Cc", ""))),
|
||||
"subject": str(message.get("Subject", "")),
|
||||
"date": _decode_date(str(message.get("Date", ""))),
|
||||
"message_id": str(message.get("Message-ID", "")),
|
||||
"body": body[:MAX_BODY_CHARS],
|
||||
"body_truncated": len(body) > MAX_BODY_CHARS,
|
||||
"html": html[:MAX_BODY_CHARS],
|
||||
"attachments": attachments,
|
||||
}
|
||||
|
||||
def search(self, folder: str, criteria: list[str], limit: int) -> dict[str, Any]:
|
||||
return self.list_messages(folder, criteria, limit, 0)
|
||||
|
||||
def set_flags(
|
||||
self, folder: str, uid: str, flags: list[str], add: bool
|
||||
) -> dict[str, Any]:
|
||||
with self._imap() as conn:
|
||||
self._select(conn, folder, readonly=False)
|
||||
command = "+FLAGS" if add else "-FLAGS"
|
||||
typ, data = conn.uid("STORE", uid, command, "(%s)" % " ".join(flags))
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Flag update failed: {_first(data)}")
|
||||
return {"uid": uid, "folder": folder, "flags": flags, "added": add}
|
||||
|
||||
def move_message(self, folder: str, uid: str, destination: str) -> dict[str, Any]:
|
||||
with self._imap() as conn:
|
||||
self._select(conn, folder, readonly=False)
|
||||
typ, data = conn.uid("COPY", uid, imap_quote(destination))
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Copy to '{destination}' failed: {_first(data)}")
|
||||
conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
|
||||
conn.expunge()
|
||||
return {"uid": uid, "from_folder": folder, "to_folder": destination}
|
||||
|
||||
def delete_message(
|
||||
self, folder: str, uid: str, trash: str | None
|
||||
) -> dict[str, Any]:
|
||||
if trash and trash != folder:
|
||||
result = self.move_message(folder, uid, trash)
|
||||
result["deleted"] = True
|
||||
return result
|
||||
with self._imap() as conn:
|
||||
self._select(conn, folder, readonly=False)
|
||||
typ, data = conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Delete failed: {_first(data)}")
|
||||
conn.expunge()
|
||||
return {"uid": uid, "folder": folder, "deleted": True}
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
to: list[str],
|
||||
subject: str,
|
||||
body: str,
|
||||
cc: list[str] | None = None,
|
||||
bcc: list[str] | None = None,
|
||||
html: str | None = None,
|
||||
in_reply_to: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
account = self.account
|
||||
if not account.smtp_host:
|
||||
raise EmailError("This account has no SMTP host configured.", kind="config")
|
||||
message = EmailMessage()
|
||||
message["From"] = (
|
||||
formataddr((account.from_name, account.from_address))
|
||||
if account.from_name
|
||||
else account.from_address
|
||||
)
|
||||
message["To"] = ", ".join(to)
|
||||
if cc:
|
||||
message["Cc"] = ", ".join(cc)
|
||||
message["Subject"] = subject
|
||||
if in_reply_to:
|
||||
message["In-Reply-To"] = in_reply_to
|
||||
message["References"] = in_reply_to
|
||||
message.set_content(body)
|
||||
if html:
|
||||
message.add_alternative(html, subtype="html")
|
||||
recipients = list(to) + list(cc or []) + list(bcc or [])
|
||||
try:
|
||||
guard_public_host_sync(account.smtp_host)
|
||||
except BlockedAddressError as exc:
|
||||
raise EmailError(str(exc), kind="blocked") from exc
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
if account.smtp_ssl:
|
||||
server: smtplib.SMTP = smtplib.SMTP_SSL(
|
||||
account.smtp_host,
|
||||
account.smtp_port,
|
||||
timeout=self.timeout,
|
||||
context=context,
|
||||
)
|
||||
else:
|
||||
server = smtplib.SMTP(
|
||||
account.smtp_host, account.smtp_port, timeout=self.timeout
|
||||
)
|
||||
if account.smtp_starttls:
|
||||
server.starttls(context=context)
|
||||
with server:
|
||||
if account.username and account.password:
|
||||
server.login(account.username, account.password)
|
||||
server.send_message(
|
||||
message, from_addr=account.from_address, to_addrs=recipients
|
||||
)
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
raise EmailError(f"Sending failed: {exc}", kind="send") from exc
|
||||
return {
|
||||
"sent": True,
|
||||
"from": account.from_address,
|
||||
"to": to,
|
||||
"cc": cc or [],
|
||||
"bcc": bcc or [],
|
||||
"subject": subject,
|
||||
}
|
||||
|
||||
|
||||
def _first(data: Any) -> str:
|
||||
if not data:
|
||||
return ""
|
||||
head = data[0]
|
||||
if isinstance(head, bytes):
|
||||
return head.decode("utf-8", "replace")
|
||||
return str(head)
|
||||
|
||||
|
||||
def _first_descriptor(data: Any) -> bytes | None:
|
||||
for part in data or []:
|
||||
if isinstance(part, tuple) and part:
|
||||
return part[0]
|
||||
return None
|
||||
|
||||
|
||||
def _first_payload(data: Any) -> bytes | None:
|
||||
for part in data or []:
|
||||
if isinstance(part, tuple) and len(part) >= 2:
|
||||
return part[1]
|
||||
return None
|
||||
@@ -0,0 +1,55 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _as_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _as_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailAccount:
|
||||
label: str
|
||||
imap_host: str
|
||||
imap_port: int
|
||||
imap_ssl: bool
|
||||
imap_starttls: bool
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_ssl: bool
|
||||
smtp_starttls: bool
|
||||
username: str
|
||||
password: str
|
||||
from_address: str
|
||||
from_name: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: dict[str, Any]) -> "EmailAccount":
|
||||
username = str(row.get("username") or "")
|
||||
return cls(
|
||||
label=str(row.get("label") or ""),
|
||||
imap_host=str(row.get("imap_host") or ""),
|
||||
imap_port=_as_int(row.get("imap_port"), 993),
|
||||
imap_ssl=_as_bool(row.get("imap_ssl")),
|
||||
imap_starttls=_as_bool(row.get("imap_starttls")),
|
||||
smtp_host=str(row.get("smtp_host") or ""),
|
||||
smtp_port=_as_int(row.get("smtp_port"), 587),
|
||||
smtp_ssl=_as_bool(row.get("smtp_ssl")),
|
||||
smtp_starttls=_as_bool(row.get("smtp_starttls")),
|
||||
username=username,
|
||||
password=str(row.get("password") or ""),
|
||||
from_address=str(row.get("from_address") or username),
|
||||
from_name=str(row.get("from_name") or ""),
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class EmailError(Exception):
|
||||
def __init__(self, message: str, *, kind: str = "error") -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.kind = kind
|
||||
@@ -99,6 +99,12 @@ async def _ai_usage(match: re.Match) -> dict:
|
||||
return build_analytics(hours, top_n=10, pricing=pricing)
|
||||
|
||||
|
||||
async def _backups(_match: re.Match) -> dict:
|
||||
from devplacepy.routers.admin.backups import _dashboard
|
||||
|
||||
return _dashboard(can_download=False)
|
||||
|
||||
|
||||
VIEWS = [
|
||||
(re.compile(r"^container\.list$"), _container_list, 4.0),
|
||||
(re.compile(rf"^project\.(?P<slug>{_SEGMENT})\.containers$"), _project_containers, 3.0),
|
||||
@@ -108,6 +114,7 @@ VIEWS = [
|
||||
(re.compile(r"^admin\.services$"), _services, 5.0),
|
||||
(re.compile(rf"^admin\.services\.(?P<name>{_SEGMENT})$"), _service_detail, 5.0),
|
||||
(re.compile(r"^admin\.ai-usage\.(?P<hours>\d+)$"), _ai_usage, 15.0),
|
||||
(re.compile(r"^admin\.backups$"), _backups, 8.0),
|
||||
]
|
||||
|
||||
|
||||
@@ -115,7 +122,7 @@ class LiveViewRelayService(BaseService):
|
||||
title = "Live view relay"
|
||||
description = (
|
||||
"Pushes admin live-view snapshots (container list and instances, bot fleet, "
|
||||
"background services, AI usage) onto the pub/sub bus, computed only for topics "
|
||||
"background services, AI usage, backups) onto the pub/sub bus, computed only for topics "
|
||||
"that currently have subscribers. Runs on the service lock owner where pub/sub "
|
||||
"subscribers converge, replacing per-client HTTP polling with server push."
|
||||
)
|
||||
|
||||
@@ -25,7 +25,21 @@ def _now() -> datetime:
|
||||
|
||||
|
||||
def _iso(moment: datetime) -> str:
|
||||
return moment.isoformat()
|
||||
return moment.isoformat(timespec="microseconds")
|
||||
|
||||
|
||||
def _hours_since(iso_value: Optional[str], now: datetime) -> float:
|
||||
try:
|
||||
moment = datetime.fromisoformat(iso_value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return max(0.0, (now - moment).total_seconds() / 3600.0)
|
||||
|
||||
|
||||
def _project_monthly(cost: float, observed_hours: float) -> float:
|
||||
if observed_hours >= 1.0:
|
||||
return cost / observed_hours * 24 * 30
|
||||
return cost * 30
|
||||
|
||||
|
||||
def _pset(values: list[float]) -> dict:
|
||||
@@ -163,6 +177,11 @@ def build_analytics(
|
||||
first_hour[owner] = bucket
|
||||
|
||||
total_cost = sum(float(r.get("cost_usd") or 0) for r in rows)
|
||||
token_cost = sum(
|
||||
float(r.get("cost_usd") or 0)
|
||||
for r in rows
|
||||
if int(r.get("total_tokens") or 0) > 0
|
||||
)
|
||||
input_cost = sum(float(r.get("input_cost_usd") or 0) for r in rows)
|
||||
output_cost = sum(float(r.get("output_cost_usd") or 0) for r in rows)
|
||||
cost_this_hour = sum(
|
||||
@@ -170,8 +189,12 @@ def build_analytics(
|
||||
for r in rows
|
||||
if r["created_at"][:13] == hour_start
|
||||
)
|
||||
cost_24h = sum(
|
||||
float(r.get("cost_usd") or 0) for r in rows if r["created_at"] >= day_cutoff
|
||||
last_24h_rows = [r for r in rows if r["created_at"] >= day_cutoff]
|
||||
cost_24h = sum(float(r.get("cost_usd") or 0) for r in last_24h_rows)
|
||||
observed_24h_hours = (
|
||||
min(24.0, _hours_since(last_24h_rows[0]["created_at"], now))
|
||||
if last_24h_rows
|
||||
else 0.0
|
||||
)
|
||||
|
||||
prompt_total = sum(int(r.get("prompt_tokens") or 0) for r in rows)
|
||||
@@ -187,7 +210,7 @@ def build_analytics(
|
||||
chat_cache_hits = sum(
|
||||
int(r.get("cache_hit_tokens") or 0)
|
||||
for r in rows
|
||||
if r.get("backend") == "chat"
|
||||
if r.get("backend") == "chat" and not int(r.get("native_cost") or 0)
|
||||
)
|
||||
caching_savings = chat_cache_hits / PER_MILLION * rate_delta
|
||||
|
||||
@@ -308,8 +331,8 @@ def build_analytics(
|
||||
"output_usd": round(output_cost, 6),
|
||||
"this_hour_usd": round(cost_this_hour, 6),
|
||||
"last_24h_usd": round(cost_24h, 6),
|
||||
"projected_monthly_usd": round(cost_24h * 30, 2),
|
||||
"effective_per_1k_tokens_usd": round(total_cost / total_tokens * 1000, 6)
|
||||
"projected_monthly_usd": round(_project_monthly(cost_24h, observed_24h_hours), 2),
|
||||
"effective_per_1k_tokens_usd": round(token_cost / total_tokens * 1000, 6)
|
||||
if total_tokens
|
||||
else 0.0,
|
||||
"caching_savings_usd": round(caching_savings, 6),
|
||||
@@ -509,7 +532,9 @@ def build_user_usage(
|
||||
avg_latency = round(sum(latencies) / len(latencies), 1) if latencies else 0.0
|
||||
tps = _positive(rows, "tokens_per_second")
|
||||
avg_tps = round(sum(tps) / len(tps), 2) if tps else 0.0
|
||||
cost_per_hour = total_cost / hours
|
||||
observed_hours = min(float(hours), _hours_since(rows[0]["created_at"], now))
|
||||
rate_hours = observed_hours if observed_hours >= 1.0 else float(hours)
|
||||
cost_per_hour = total_cost / rate_hours
|
||||
cost_per_request = total_cost / requests if requests else 0.0
|
||||
|
||||
return {
|
||||
@@ -539,7 +564,9 @@ def build_user_usage(
|
||||
"by_backend": _top_group(rows, lambda r: r.get("backend") or "unknown", 0),
|
||||
"hourly": _user_hourly(rows),
|
||||
"notes": {
|
||||
"projection": "30-day projection extrapolates the full 24h spend (24h cost x 30)"
|
||||
"projection": "30-day projection from the observed hourly burn rate "
|
||||
"(per-hour spend x 24 x 30); windows with under an hour of activity fall "
|
||||
"back to a conservative window-average to avoid over-extrapolation"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ class GatewayRuntime:
|
||||
handle_start = time.monotonic()
|
||||
messages = body.get("messages", []) or []
|
||||
|
||||
vision_cost = 0.0
|
||||
if cfg["gateway_vision_enabled"]:
|
||||
augmenter = VisionAugmenter(
|
||||
cfg["gateway_vision_url"],
|
||||
@@ -238,6 +239,7 @@ class GatewayRuntime:
|
||||
)
|
||||
messages = await augmenter.augment_messages(client, messages)
|
||||
self.vision_calls += augmenter.calls
|
||||
vision_cost = augmenter.cost_usd
|
||||
|
||||
messages = apply_system_directives(messages, cfg.get("gateway_system_preamble", ""))
|
||||
|
||||
@@ -300,9 +302,13 @@ class GatewayRuntime:
|
||||
base["success"] = success
|
||||
base["error_category"] = category
|
||||
base["usage"] = usage
|
||||
return usage_response_headers(
|
||||
self._ledger.record(base, pricing, context_map)
|
||||
)
|
||||
row = self._ledger.record(base, pricing, context_map)
|
||||
headers = usage_response_headers(row)
|
||||
if vision_cost and headers:
|
||||
chat_cost = float((row or {}).get("cost_usd") or 0.0)
|
||||
headers["X-Gateway-Cost-USD"] = f"{chat_cost + vision_cost:.8f}"
|
||||
headers["X-Gateway-Vision-Cost-USD"] = f"{vision_cost:.8f}"
|
||||
return headers
|
||||
|
||||
if timing["circuit_open"]:
|
||||
resp_headers = finalize(503, False, "circuit_open")
|
||||
@@ -370,6 +376,7 @@ class GatewayRuntime:
|
||||
self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None
|
||||
):
|
||||
log = log or (lambda message: None)
|
||||
vision_cost = 0.0
|
||||
overlay = embed_overlay(body.get("model"), cfg)
|
||||
if overlay:
|
||||
cfg = {**cfg, **overlay}
|
||||
@@ -465,9 +472,13 @@ class GatewayRuntime:
|
||||
base["success"] = success
|
||||
base["error_category"] = category
|
||||
base["usage"] = usage
|
||||
return usage_response_headers(
|
||||
self._ledger.record(base, pricing, context_map)
|
||||
)
|
||||
row = self._ledger.record(base, pricing, context_map)
|
||||
headers = usage_response_headers(row)
|
||||
if vision_cost and headers:
|
||||
chat_cost = float((row or {}).get("cost_usd") or 0.0)
|
||||
headers["X-Gateway-Cost-USD"] = f"{chat_cost + vision_cost:.8f}"
|
||||
headers["X-Gateway-Vision-Cost-USD"] = f"{vision_cost:.8f}"
|
||||
return headers
|
||||
|
||||
if timing["circuit_open"]:
|
||||
resp_headers = finalize(503, False, "circuit_open")
|
||||
@@ -538,6 +549,7 @@ class GatewayRuntime:
|
||||
log=None,
|
||||
):
|
||||
log = log or (lambda message: None)
|
||||
vision_cost = 0.0
|
||||
client, sem = self._ensure(cfg)
|
||||
pricing = pricing_from_cfg(cfg)
|
||||
context_map = parse_context_map(cfg.get("gateway_model_context_map"))
|
||||
@@ -583,9 +595,13 @@ class GatewayRuntime:
|
||||
base["success"] = success
|
||||
base["error_category"] = category
|
||||
base["usage"] = usage
|
||||
return usage_response_headers(
|
||||
self._ledger.record(base, pricing, context_map)
|
||||
)
|
||||
row = self._ledger.record(base, pricing, context_map)
|
||||
headers = usage_response_headers(row)
|
||||
if vision_cost and headers:
|
||||
chat_cost = float((row or {}).get("cost_usd") or 0.0)
|
||||
headers["X-Gateway-Cost-USD"] = f"{chat_cost + vision_cost:.8f}"
|
||||
headers["X-Gateway-Vision-Cost-USD"] = f"{vision_cost:.8f}"
|
||||
return headers
|
||||
|
||||
if timing["circuit_open"]:
|
||||
resp_headers = finalize(503, False, "circuit_open")
|
||||
|
||||
@@ -354,6 +354,7 @@ def chat_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[dic
|
||||
overlay["gateway_vision_enabled"] = True
|
||||
overlay["gateway_vision_model"] = route.vision_model
|
||||
overlay["gateway_vision_price_input_per_m"] = route.price_input_per_m
|
||||
overlay["gateway_vision_price_output_per_m"] = route.price_output_per_m
|
||||
vision_provider = route.vision_provider or route.provider
|
||||
if vision_provider:
|
||||
_provider_overlay(
|
||||
|
||||
@@ -25,7 +25,7 @@ def _now() -> datetime:
|
||||
|
||||
|
||||
def _iso(moment: datetime) -> str:
|
||||
return moment.isoformat()
|
||||
return moment.isoformat(timespec="microseconds")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -120,24 +120,30 @@ def normalize_usage(usage: Optional[dict]) -> dict:
|
||||
def compute_cost(
|
||||
usage: dict, norm: dict, pricing: Pricing, backend: str
|
||||
) -> tuple[float, float, float, bool]:
|
||||
native = usage.get("cost") if isinstance(usage, dict) else None
|
||||
if isinstance(native, (int, float)) and not isinstance(native, bool):
|
||||
total = float(native)
|
||||
denom = norm["prompt"] + norm["completion"]
|
||||
input_cost = total * norm["prompt"] / denom if denom > 0 else 0.0
|
||||
return total, input_cost, total - input_cost, True
|
||||
if backend == "vision":
|
||||
input_cost = norm["prompt"] / PER_MILLION * pricing.vision_input_per_m
|
||||
output_cost = norm["completion"] / PER_MILLION * pricing.vision_output_per_m
|
||||
return input_cost + output_cost, input_cost, output_cost, False
|
||||
if backend == "embed":
|
||||
elif backend == "embed":
|
||||
input_cost = norm["prompt"] / PER_MILLION * pricing.embed_input_per_m
|
||||
return input_cost, input_cost, 0.0, False
|
||||
input_cost = (
|
||||
norm["cache_hit"] / PER_MILLION * pricing.chat_cache_hit_per_m
|
||||
+ norm["cache_miss"] / PER_MILLION * pricing.chat_cache_miss_per_m
|
||||
)
|
||||
output_cost = norm["completion"] / PER_MILLION * pricing.chat_output_per_m
|
||||
output_cost = 0.0
|
||||
else:
|
||||
input_cost = (
|
||||
norm["cache_hit"] / PER_MILLION * pricing.chat_cache_hit_per_m
|
||||
+ norm["cache_miss"] / PER_MILLION * pricing.chat_cache_miss_per_m
|
||||
)
|
||||
output_cost = norm["completion"] / PER_MILLION * pricing.chat_output_per_m
|
||||
native = usage.get("cost") if isinstance(usage, dict) else None
|
||||
if isinstance(native, (int, float)) and not isinstance(native, bool):
|
||||
total = max(0.0, float(native))
|
||||
modeled = input_cost + output_cost
|
||||
if modeled > 0:
|
||||
input_share = input_cost / modeled
|
||||
elif norm["prompt"] + norm["completion"] > 0:
|
||||
input_share = norm["prompt"] / (norm["prompt"] + norm["completion"])
|
||||
else:
|
||||
input_share = 0.0
|
||||
native_input = total * input_share
|
||||
return total, native_input, total - native_input, True
|
||||
return input_cost + output_cost, input_cost, output_cost, False
|
||||
|
||||
|
||||
@@ -249,6 +255,35 @@ def usage_response_headers(row: Optional[dict]) -> dict:
|
||||
return headers
|
||||
|
||||
|
||||
def parse_usage_headers(headers) -> Optional[dict]:
|
||||
if not headers or "X-Gateway-Cost-USD" not in headers:
|
||||
return None
|
||||
|
||||
def _int(name: str) -> int:
|
||||
try:
|
||||
return int(headers.get(name) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def _float(name: str) -> float:
|
||||
try:
|
||||
return float(headers.get(name) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
return {
|
||||
"calls": 1,
|
||||
"model": headers.get("X-Gateway-Model") or "",
|
||||
"prompt_tokens": _int("X-Gateway-Prompt-Tokens"),
|
||||
"completion_tokens": _int("X-Gateway-Completion-Tokens"),
|
||||
"total_tokens": _int("X-Gateway-Total-Tokens"),
|
||||
"cost_usd": _float("X-Gateway-Cost-USD"),
|
||||
"native_cost": headers.get("X-Gateway-Cost-Native") == "1",
|
||||
"upstream_latency_ms": _float("X-Gateway-Upstream-Latency-Ms"),
|
||||
"total_latency_ms": _float("X-Gateway-Total-Latency-Ms"),
|
||||
}
|
||||
|
||||
|
||||
class GatewayUsageLedger:
|
||||
def record(self, raw: dict, pricing: Pricing, context_map: dict) -> Optional[dict]:
|
||||
try:
|
||||
|
||||
@@ -106,11 +106,12 @@ class VisionAugmenter:
|
||||
self.pricing = pricing
|
||||
self.context_map = context_map or {}
|
||||
self.calls = 0
|
||||
self.cost_usd = 0.0
|
||||
|
||||
def _record(self, latency_ms, status_code, success, category, usage):
|
||||
if self.ledger is None or self.pricing is None:
|
||||
return
|
||||
self.ledger.record(
|
||||
row = self.ledger.record(
|
||||
{
|
||||
"owner_kind": self.owner[0],
|
||||
"owner_id": self.owner[1],
|
||||
@@ -129,6 +130,8 @@ class VisionAugmenter:
|
||||
self.pricing,
|
||||
self.context_map,
|
||||
)
|
||||
if row:
|
||||
self.cost_usd += float(row.get("cost_usd") or 0.0)
|
||||
|
||||
async def _describe_one(self, client: httpx.AsyncClient, image_block: dict) -> str:
|
||||
if not self.vision_key:
|
||||
|
||||
@@ -5,9 +5,12 @@ from __future__ import annotations
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.services.dbapi.policy import caller_for as _dbapi_caller
|
||||
from devplacepy.utils import get_current_user, is_admin
|
||||
from devplacepy.database import get_setting, internal_gateway_key
|
||||
from devplacepy.utils import (
|
||||
_user_from_api_key,
|
||||
get_current_user,
|
||||
is_admin,
|
||||
)
|
||||
|
||||
TOPIC_NAME = re.compile(r"^[A-Za-z0-9_.*-]{1,128}$")
|
||||
MAX_PAYLOAD_BYTES = 64 * 1024
|
||||
@@ -28,14 +31,31 @@ def guests_enabled() -> bool:
|
||||
return get_setting("pubsub_allow_guests", "0") == "1"
|
||||
|
||||
|
||||
def _bearer_key(scope) -> str:
|
||||
key = scope.headers.get("x-api-key", "").strip()
|
||||
if key:
|
||||
return key
|
||||
scheme, _, credentials = scope.headers.get("authorization", "").partition(" ")
|
||||
if scheme.lower() == "bearer":
|
||||
return credentials.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_actor(scope) -> Actor:
|
||||
caller = _dbapi_caller(scope)
|
||||
if caller is not None:
|
||||
return Actor(kind=caller.kind, uid=caller.uid, username=caller.username)
|
||||
user = get_current_user(scope)
|
||||
if user and is_admin(user):
|
||||
return Actor(kind="admin", uid=user["uid"], username=user.get("username", ""))
|
||||
key = _bearer_key(scope)
|
||||
if key and key == internal_gateway_key():
|
||||
return Actor(kind="internal", uid="internal", username="internal")
|
||||
if key:
|
||||
keyed = _user_from_api_key(key)
|
||||
if keyed and is_admin(keyed):
|
||||
return Actor(
|
||||
kind="admin", uid=keyed["uid"], username=keyed.get("username", "")
|
||||
)
|
||||
if user:
|
||||
kind = "admin" if is_admin(user) else "user"
|
||||
return Actor(kind=kind, uid=user["uid"], username=user.get("username", ""))
|
||||
return Actor(kind="user", uid=user["uid"], username=user.get("username", ""))
|
||||
return Actor(kind="guest", uid="", username="guest")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["TelegramService"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "TelegramService":
|
||||
from .service import TelegramService
|
||||
|
||||
return TelegramService
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,142 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from devplacepy import stealth
|
||||
|
||||
API_ROOT = "https://api.telegram.org"
|
||||
|
||||
|
||||
class TelegramBackend(ABC):
|
||||
@abstractmethod
|
||||
async def get_updates(self, offset: int, timeout: int) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def send_message(
|
||||
self, chat_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def edit_message_text(
|
||||
self, chat_id: int, message_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def send_chat_action(self, chat_id: int, action: str) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get_file(self, file_id: str) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def download(self, file_path: str) -> bytes: ...
|
||||
|
||||
|
||||
class HttpTelegramBackend(TelegramBackend):
|
||||
def __init__(self, token: str, timeout: float = 60.0) -> None:
|
||||
self._token = token
|
||||
self._timeout = timeout
|
||||
|
||||
def _method_url(self, method: str) -> str:
|
||||
return f"{API_ROOT}/bot{self._token}/{method}"
|
||||
|
||||
async def _call(self, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||
async with stealth.stealth_async_client(timeout=timeout) as client:
|
||||
response = await client.post(self._method_url(method), json=payload)
|
||||
try:
|
||||
return response.json()
|
||||
except (ValueError, TypeError):
|
||||
return {"ok": False, "description": f"non-json response ({response.status_code})"}
|
||||
|
||||
async def get_updates(self, offset: int, timeout: int) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"getUpdates",
|
||||
{"offset": offset, "timeout": timeout, "allowed_updates": ["message"]},
|
||||
timeout=timeout + 15,
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self, chat_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
if parse_mode:
|
||||
payload["parse_mode"] = parse_mode
|
||||
return await self._call("sendMessage", payload, self._timeout)
|
||||
|
||||
async def edit_message_text(
|
||||
self, chat_id: int, message_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"text": text,
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
if parse_mode:
|
||||
payload["parse_mode"] = parse_mode
|
||||
return await self._call("editMessageText", payload, self._timeout)
|
||||
|
||||
async def send_chat_action(self, chat_id: int, action: str) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"sendChatAction", {"chat_id": chat_id, "action": action}, self._timeout
|
||||
)
|
||||
|
||||
async def get_file(self, file_id: str) -> dict[str, Any]:
|
||||
return await self._call("getFile", {"file_id": file_id}, self._timeout)
|
||||
|
||||
async def download(self, file_path: str) -> bytes:
|
||||
url = f"{API_ROOT}/file/bot{self._token}/{file_path}"
|
||||
async with stealth.stealth_async_client(timeout=self._timeout) as client:
|
||||
response = await client.get(url)
|
||||
if response.status_code != 200:
|
||||
return b""
|
||||
return response.content
|
||||
|
||||
|
||||
class FakeTelegramBackend(TelegramBackend):
|
||||
def __init__(self) -> None:
|
||||
self.inbound: list[dict[str, Any]] = []
|
||||
self.sent: list[dict[str, Any]] = []
|
||||
self.edited: list[dict[str, Any]] = []
|
||||
self.actions: list[dict[str, Any]] = []
|
||||
self.files: dict[str, bytes] = {}
|
||||
self._message_id = 1000
|
||||
|
||||
async def get_updates(self, offset: int, timeout: int) -> dict[str, Any]:
|
||||
ready = [u for u in self.inbound if int(u.get("update_id", 0)) >= offset]
|
||||
self.inbound = []
|
||||
return {"ok": True, "result": ready}
|
||||
|
||||
async def send_message(
|
||||
self, chat_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]:
|
||||
self._message_id += 1
|
||||
self.sent.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
|
||||
return {"ok": True, "result": {"message_id": self._message_id}}
|
||||
|
||||
async def edit_message_text(
|
||||
self, chat_id: int, message_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]:
|
||||
self.edited.append(
|
||||
{"chat_id": chat_id, "message_id": message_id, "text": text, "parse_mode": parse_mode}
|
||||
)
|
||||
return {"ok": True, "result": {"message_id": message_id}}
|
||||
|
||||
async def send_chat_action(self, chat_id: int, action: str) -> dict[str, Any]:
|
||||
self.actions.append({"chat_id": chat_id, "action": action})
|
||||
return {"ok": True}
|
||||
|
||||
async def get_file(self, file_id: str) -> dict[str, Any]:
|
||||
if file_id not in self.files:
|
||||
return {"ok": False}
|
||||
return {"ok": True, "result": {"file_path": f"photos/{file_id}.jpg"}}
|
||||
|
||||
async def download(self, file_path: str) -> bytes:
|
||||
key = file_path.split("/")[-1].rsplit(".", 1)[0]
|
||||
return self.files.get(key, b"")
|
||||
@@ -0,0 +1,262 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.utils import is_admin, is_primary_admin
|
||||
|
||||
from . import store
|
||||
from .format import markdown_to_telegram_html, split_for_telegram
|
||||
|
||||
logger = logging.getLogger("telegram.bridge")
|
||||
|
||||
TURN_TIMEOUT_SECONDS = 300.0
|
||||
TYPING_INTERVAL_SECONDS = 4.0
|
||||
PROGRESS_EDIT_INTERVAL_SECONDS = 1.5
|
||||
ATTEMPT_LIMIT = 5
|
||||
ATTEMPT_WINDOW_SECONDS = 600.0
|
||||
THINKING_PLACEHOLDER = "<i>Devii is thinking...</i>"
|
||||
|
||||
WELCOME = (
|
||||
"Welcome to Devii on Telegram. To connect your DevPlace account, open your profile, "
|
||||
"request a Telegram pairing code, and send me the four digit code here."
|
||||
)
|
||||
PAIRED_TEMPLATE = "Paired. Hello {username}. Send me a message and I will help."
|
||||
BAD_CODE = (
|
||||
"That code is invalid or expired. Request a fresh code from your DevPlace profile and "
|
||||
"send it here."
|
||||
)
|
||||
TOO_MANY = "Too many attempts. Wait a few minutes, request a new code, and try again."
|
||||
|
||||
|
||||
class TelegramConnection:
|
||||
def __init__(self, service: Any, session: Any, chat_id: int, message_id: int | None) -> None:
|
||||
self._service = service
|
||||
self._session = session
|
||||
self._chat_id = chat_id
|
||||
self._message_id = message_id
|
||||
self.done = asyncio.Event()
|
||||
self._finalized = False
|
||||
self._last_status = ""
|
||||
self._last_edit = 0.0
|
||||
self._typing_task = asyncio.create_task(self._typing_loop())
|
||||
|
||||
async def send_json(self, payload: dict[str, Any]) -> None:
|
||||
kind = payload.get("type")
|
||||
if kind == "reply":
|
||||
await self._finalize(markdown_to_telegram_html(payload.get("text", "")) or "(no response)")
|
||||
elif kind == "error":
|
||||
await self._finalize(html.escape("Error: " + str(payload.get("text", ""))))
|
||||
elif kind in ("status", "trace"):
|
||||
await self._progress(payload)
|
||||
elif kind in ("avatar", "client"):
|
||||
query_id = payload.get("id")
|
||||
if query_id:
|
||||
self._session.resolve_query(
|
||||
query_id, {"error": "No browser is attached (Telegram session)."}
|
||||
)
|
||||
|
||||
async def _typing_loop(self) -> None:
|
||||
try:
|
||||
while not self.done.is_set():
|
||||
await self._service.chat_action(self._chat_id, "typing")
|
||||
await asyncio.sleep(TYPING_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 - typing is cosmetic, never fail a turn over it
|
||||
pass
|
||||
|
||||
async def _progress(self, payload: dict[str, Any]) -> None:
|
||||
if self._finalized or self._message_id is None:
|
||||
return
|
||||
if payload.get("type") == "trace":
|
||||
if payload.get("event") != "call":
|
||||
return
|
||||
text = f"Working... ({payload.get('name', '')})"
|
||||
else:
|
||||
text = str(payload.get("text", ""))
|
||||
if not text or text == self._last_status:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - self._last_edit < PROGRESS_EDIT_INTERVAL_SECONDS:
|
||||
return
|
||||
self._last_status = text
|
||||
self._last_edit = now
|
||||
await self._service.edit(
|
||||
self._chat_id, self._message_id, f"<i>{html.escape(text)}</i>"
|
||||
)
|
||||
|
||||
async def _finalize(self, html_text: str) -> None:
|
||||
if self._finalized:
|
||||
return
|
||||
self._finalized = True
|
||||
self._stop_typing()
|
||||
chunks = split_for_telegram(html_text)
|
||||
first = chunks[0] if chunks else "(no response)"
|
||||
if self._message_id is not None:
|
||||
ok = await self._service.edit(self._chat_id, self._message_id, first)
|
||||
if not ok:
|
||||
await self._service.send(self._chat_id, first)
|
||||
else:
|
||||
await self._service.send(self._chat_id, first)
|
||||
for chunk in chunks[1:]:
|
||||
await self._service.send(self._chat_id, chunk)
|
||||
self.done.set()
|
||||
|
||||
def _stop_typing(self) -> None:
|
||||
if self._typing_task is not None and not self._typing_task.done():
|
||||
self._typing_task.cancel()
|
||||
|
||||
|
||||
class TelegramBridge:
|
||||
def __init__(self, service: Any, max_concurrent: int = 8) -> None:
|
||||
self._service = service
|
||||
self._semaphore = asyncio.Semaphore(max(1, max_concurrent))
|
||||
self._chat_locks: dict[int, asyncio.Lock] = {}
|
||||
self._attempts: dict[int, list[float]] = {}
|
||||
|
||||
def _chat_lock(self, chat_id: int) -> asyncio.Lock:
|
||||
lock = self._chat_locks.get(chat_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
return lock
|
||||
|
||||
async def handle_inbound(self, event: dict[str, Any]) -> None:
|
||||
chat_id = int(event["chat_id"])
|
||||
from_id = int(event["from_id"])
|
||||
text = str(event.get("text", "")).strip()
|
||||
images = [img for img in event.get("images", []) if img]
|
||||
link = store.user_for_chat(chat_id)
|
||||
if link is None:
|
||||
await self._handle_unpaired(chat_id, from_id, text)
|
||||
return
|
||||
if int(link.get("from_id", 0)) != from_id:
|
||||
return
|
||||
user = get_table("users").find_one(uid=link["user_uid"])
|
||||
if not user:
|
||||
await self._service.send(
|
||||
chat_id, "Your DevPlace account was not found. Please re-pair from your profile."
|
||||
)
|
||||
return
|
||||
async with self._chat_lock(chat_id):
|
||||
async with self._semaphore:
|
||||
await self._run_turn(chat_id, user, text, images)
|
||||
|
||||
async def _handle_unpaired(self, chat_id: int, from_id: int, text: str) -> None:
|
||||
if text.isdigit() and len(text) == 4:
|
||||
if self._too_many_attempts(chat_id):
|
||||
await self._service.send(chat_id, TOO_MANY)
|
||||
return
|
||||
user = store.verify_code(text, chat_id, from_id)
|
||||
if user:
|
||||
self._attempts.pop(chat_id, None)
|
||||
await self._service.send(
|
||||
chat_id, PAIRED_TEMPLATE.format(username=user.get("username", "there"))
|
||||
)
|
||||
audit.record_system(
|
||||
"telegram.pair.success",
|
||||
actor_kind="user",
|
||||
actor_uid=user["uid"],
|
||||
actor_username=user.get("username", ""),
|
||||
summary=f"Telegram paired for {user.get('username', user['uid'])}",
|
||||
metadata={"chat_id": chat_id},
|
||||
)
|
||||
return
|
||||
self._register_attempt(chat_id)
|
||||
await self._service.send(chat_id, BAD_CODE)
|
||||
audit.record_system(
|
||||
"telegram.pair.failure",
|
||||
actor_kind="guest",
|
||||
result="failure",
|
||||
summary="Telegram pairing code rejected",
|
||||
metadata={"chat_id": chat_id},
|
||||
)
|
||||
return
|
||||
await self._service.send(chat_id, WELCOME)
|
||||
|
||||
def _too_many_attempts(self, chat_id: int) -> bool:
|
||||
now = time.monotonic()
|
||||
recent = [t for t in self._attempts.get(chat_id, []) if now - t < ATTEMPT_WINDOW_SECONDS]
|
||||
self._attempts[chat_id] = recent
|
||||
return len(recent) >= ATTEMPT_LIMIT
|
||||
|
||||
def _register_attempt(self, chat_id: int) -> None:
|
||||
self._attempts.setdefault(chat_id, []).append(time.monotonic())
|
||||
|
||||
async def _run_turn(
|
||||
self, chat_id: int, user: dict[str, Any], text: str, images: list[str]
|
||||
) -> None:
|
||||
devii = service_manager.get_service("devii")
|
||||
if devii is None or not devii.is_enabled():
|
||||
await self._service.send(chat_id, "Devii is currently unavailable.")
|
||||
return
|
||||
owner_id = user["uid"]
|
||||
owner_is_admin = is_admin(user)
|
||||
owner_is_primary_admin = is_primary_admin(user)
|
||||
if devii.quota_exceeded("user", owner_id, owner_is_admin):
|
||||
limit = devii.daily_limit_for("user", owner_is_admin)
|
||||
audit.record_system(
|
||||
"ai.quota.exceeded",
|
||||
actor_kind="user",
|
||||
actor_uid=owner_id,
|
||||
actor_username=user.get("username", ""),
|
||||
actor_role="admin" if owner_is_admin else "member",
|
||||
origin="telegram",
|
||||
via_agent=1,
|
||||
result="denied",
|
||||
summary=f"Telegram AI request by {user.get('username', owner_id)} blocked - 24h quota reached",
|
||||
metadata={"limit_usd": limit},
|
||||
)
|
||||
await self._service.send(
|
||||
chat_id, "Your daily AI quota is reached (100%). Please try again later."
|
||||
)
|
||||
return
|
||||
session = devii.hub().get_or_create(
|
||||
"user",
|
||||
owner_id,
|
||||
user.get("username", ""),
|
||||
user.get("api_key", ""),
|
||||
devii.instance_base_url(),
|
||||
is_admin=owner_is_admin,
|
||||
is_primary_admin=owner_is_primary_admin,
|
||||
channel="telegram",
|
||||
)
|
||||
session.set_timezone(user.get("timezone") or "")
|
||||
placeholder_id = await self._service.send(chat_id, THINKING_PLACEHOLDER)
|
||||
connection = TelegramConnection(self._service, session, chat_id, placeholder_id)
|
||||
session.attach(connection)
|
||||
content, audit_text = self._build_content(text, images)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
session.spawn_turn(content, audit_text=audit_text)
|
||||
await asyncio.wait_for(connection.done.wait(), timeout=TURN_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
await connection._finalize(
|
||||
html.escape("Devii took too long to respond. Please try again.")
|
||||
)
|
||||
except Exception: # noqa: BLE001 - never let one turn crash the bridge
|
||||
logger.exception("Telegram turn failed for %s", owner_id)
|
||||
await connection._finalize(html.escape("Something went wrong handling your message."))
|
||||
finally:
|
||||
session.detach(connection)
|
||||
self._service.record_latency(time.monotonic() - started)
|
||||
|
||||
@staticmethod
|
||||
def _build_content(text: str, images: list[str]) -> tuple[Any, str]:
|
||||
if not images:
|
||||
return text, text
|
||||
prompt = text or "Look at the attached image and respond."
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||
for uri in images:
|
||||
content.append({"type": "image_url", "image_url": {"url": uri}})
|
||||
audit_text = f"{text} [{len(images)} image(s)]".strip()
|
||||
return content, audit_text
|
||||
@@ -0,0 +1,164 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
|
||||
import mistune
|
||||
|
||||
MAX_MESSAGE_CHARS = 4096
|
||||
CHUNK_BUDGET = 3900
|
||||
|
||||
_markdown = mistune.create_markdown(
|
||||
escape=False,
|
||||
hard_wrap=True,
|
||||
plugins=["table", "strikethrough", "url"],
|
||||
)
|
||||
|
||||
INLINE_TAGS = {
|
||||
"strong": "b",
|
||||
"b": "b",
|
||||
"em": "i",
|
||||
"i": "i",
|
||||
"del": "s",
|
||||
"s": "s",
|
||||
"strike": "s",
|
||||
"u": "u",
|
||||
"ins": "u",
|
||||
"code": "code",
|
||||
"pre": "pre",
|
||||
"blockquote": "blockquote",
|
||||
}
|
||||
|
||||
BLOCK_TAGS = {"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "table", "tr"}
|
||||
HEADING_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6"}
|
||||
|
||||
|
||||
class _TelegramHtml(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self._out: list[str] = []
|
||||
self._list_stack: list[dict] = []
|
||||
self._in_heading = False
|
||||
self._in_pre = False
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list) -> None:
|
||||
mapping = dict(attrs)
|
||||
if tag == "br":
|
||||
self._out.append("\n")
|
||||
return
|
||||
if tag == "hr":
|
||||
self._out.append("\n")
|
||||
return
|
||||
if tag in HEADING_TAGS:
|
||||
self._newline_break()
|
||||
self._out.append("<b>")
|
||||
self._in_heading = True
|
||||
return
|
||||
if tag in ("ul", "ol"):
|
||||
self._newline_break()
|
||||
self._list_stack.append({"ordered": tag == "ol", "n": 0})
|
||||
return
|
||||
if tag == "li":
|
||||
self._out.append("\n")
|
||||
if self._list_stack:
|
||||
top = self._list_stack[-1]
|
||||
if top["ordered"]:
|
||||
top["n"] += 1
|
||||
self._out.append(f"{top['n']}. ")
|
||||
else:
|
||||
self._out.append("- ")
|
||||
return
|
||||
if tag in BLOCK_TAGS:
|
||||
self._newline_break()
|
||||
return
|
||||
if tag == "a":
|
||||
href = mapping.get("href", "")
|
||||
if href.startswith(("http://", "https://", "tg://")):
|
||||
self._out.append(f'<a href="{html.escape(href, quote=True)}">')
|
||||
return
|
||||
if tag == "pre":
|
||||
self._newline_break()
|
||||
self._out.append("<pre>")
|
||||
self._in_pre = True
|
||||
return
|
||||
target = INLINE_TAGS.get(tag)
|
||||
if target:
|
||||
self._out.append(f"<{target}>")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in HEADING_TAGS:
|
||||
self._out.append("</b>\n")
|
||||
self._in_heading = False
|
||||
return
|
||||
if tag in ("ul", "ol"):
|
||||
if self._list_stack:
|
||||
self._list_stack.pop()
|
||||
self._out.append("\n")
|
||||
return
|
||||
if tag in BLOCK_TAGS or tag == "li":
|
||||
return
|
||||
if tag == "a":
|
||||
if self._out and "<a href=" in "".join(self._out[-6:]):
|
||||
self._out.append("</a>")
|
||||
return
|
||||
if tag == "pre":
|
||||
self._out.append("</pre>\n")
|
||||
self._in_pre = False
|
||||
return
|
||||
target = INLINE_TAGS.get(tag)
|
||||
if target:
|
||||
self._out.append(f"</{target}>")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self._out.append(html.escape(data, quote=False))
|
||||
|
||||
def _newline_break(self) -> None:
|
||||
text = "".join(self._out)
|
||||
if text and not text.endswith("\n\n"):
|
||||
if text.endswith("\n"):
|
||||
self._out.append("\n")
|
||||
else:
|
||||
self._out.append("\n\n")
|
||||
|
||||
def result(self) -> str:
|
||||
text = "".join(self._out)
|
||||
while "\n\n\n" in text:
|
||||
text = text.replace("\n\n\n", "\n\n")
|
||||
return text.strip()
|
||||
|
||||
|
||||
def markdown_to_telegram_html(text: str) -> str:
|
||||
rendered = _markdown(text or "")
|
||||
parser = _TelegramHtml()
|
||||
parser.feed(rendered)
|
||||
parser.close()
|
||||
return parser.result()
|
||||
|
||||
|
||||
def split_for_telegram(text: str, limit: int = CHUNK_BUDGET) -> list[str]:
|
||||
if len(text) <= limit:
|
||||
return [text] if text else [""]
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for line in text.split("\n"):
|
||||
piece = line if not current else "\n" + line
|
||||
if len(current) + len(piece) <= limit:
|
||||
current += piece
|
||||
continue
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = ""
|
||||
if len(line) <= limit:
|
||||
current = line
|
||||
continue
|
||||
for start in range(0, len(line), limit):
|
||||
segment = line[start : start + limit]
|
||||
if len(segment) == limit:
|
||||
chunks.append(segment)
|
||||
else:
|
||||
current = segment
|
||||
if current:
|
||||
chunks.append(current)
|
||||
return chunks or [""]
|
||||
@@ -0,0 +1,368 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from devplacepy.config import BASE_DIR
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.services.base import BaseService, ConfigField
|
||||
|
||||
from .bridge import TelegramBridge
|
||||
from .format import markdown_to_telegram_html, split_for_telegram
|
||||
|
||||
logger = logging.getLogger("telegram.service")
|
||||
|
||||
WORKER_MODULE = "devplacepy.services.telegram.worker"
|
||||
STREAM_LIMIT = 16 * 1024 * 1024
|
||||
RESULT_TIMEOUT_SECONDS = 30.0
|
||||
STOP_GRACE_SECONDS = 5.0
|
||||
LIVE_LOG_SIZE = 200
|
||||
LATENCY_SAMPLES = 200
|
||||
|
||||
FIELD_TOKEN = "telegram_bot_token"
|
||||
FIELD_POLL_TIMEOUT = "telegram_poll_timeout"
|
||||
FIELD_CODE_TTL = "telegram_code_ttl_minutes"
|
||||
FIELD_MAX_CONCURRENT = "telegram_max_concurrent_turns"
|
||||
|
||||
|
||||
class TelegramService(BaseService):
|
||||
default_enabled = False
|
||||
min_interval = 15
|
||||
title = "Telegram Bot"
|
||||
description = (
|
||||
"Runs a Telegram bot that bridges Devii into Telegram. A user pairs their Telegram "
|
||||
"account with a four digit code from their profile, then chats with their own Devii "
|
||||
"with markdown, typing status, live message editing and image understanding. The "
|
||||
"long-poller runs as a supervised subprocess and is off by default."
|
||||
)
|
||||
config_fields = [
|
||||
ConfigField(
|
||||
FIELD_TOKEN,
|
||||
"Bot token",
|
||||
type="str",
|
||||
default="",
|
||||
secret=True,
|
||||
help="Telegram bot token from @BotFather. Required for the service to start.",
|
||||
group="Telegram",
|
||||
),
|
||||
ConfigField(
|
||||
FIELD_POLL_TIMEOUT,
|
||||
"Long-poll timeout (seconds)",
|
||||
type="int",
|
||||
default=25,
|
||||
minimum=1,
|
||||
maximum=50,
|
||||
help="getUpdates long-poll hold time. 25-30 is recommended.",
|
||||
group="Telegram",
|
||||
),
|
||||
ConfigField(
|
||||
FIELD_CODE_TTL,
|
||||
"Pairing code lifetime (minutes)",
|
||||
type="int",
|
||||
default=60,
|
||||
minimum=1,
|
||||
maximum=1440,
|
||||
help="How long a pairing code requested from a profile stays valid.",
|
||||
group="Telegram",
|
||||
),
|
||||
ConfigField(
|
||||
FIELD_MAX_CONCURRENT,
|
||||
"Max concurrent turns",
|
||||
type="int",
|
||||
default=8,
|
||||
minimum=1,
|
||||
maximum=64,
|
||||
help="Upper bound on Devii turns processed at once across all chats.",
|
||||
group="Telegram",
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(name="telegram", interval_seconds=30)
|
||||
self._proc: asyncio.subprocess.Process | None = None
|
||||
self._tasks: list[asyncio.Task] = []
|
||||
self._pending: dict[int, asyncio.Future] = {}
|
||||
self._req_seq = 0
|
||||
self._stdin_lock = asyncio.Lock()
|
||||
self._live_logs: deque[str] = deque(maxlen=LIVE_LOG_SIZE)
|
||||
self._bridge: TelegramBridge | None = None
|
||||
self._stats = self._fresh_stats()
|
||||
|
||||
def _fresh_stats(self) -> dict:
|
||||
return {"in": 0, "out": 0, "edits": 0, "errors": 0, "latencies": deque(maxlen=LATENCY_SAMPLES)}
|
||||
|
||||
def _token(self) -> str:
|
||||
return get_setting(FIELD_TOKEN, "").strip()
|
||||
|
||||
def _alive(self) -> bool:
|
||||
return self._proc is not None and self._proc.returncode is None
|
||||
|
||||
async def on_enable(self) -> None:
|
||||
if not self._token():
|
||||
self.log("No bot token configured; waiting for configuration")
|
||||
return
|
||||
await self._spawn()
|
||||
|
||||
async def on_disable(self) -> None:
|
||||
await self._terminate()
|
||||
|
||||
async def run_once(self) -> None:
|
||||
if not self.is_enabled():
|
||||
return
|
||||
if not self._token():
|
||||
return
|
||||
if not self._alive():
|
||||
self.log("Worker not running; starting it")
|
||||
await self._spawn()
|
||||
|
||||
async def _spawn(self) -> None:
|
||||
if self._alive():
|
||||
return
|
||||
self._bridge = TelegramBridge(self, max_concurrent=self._max_concurrent())
|
||||
self._stats = self._fresh_stats()
|
||||
env = {
|
||||
**os.environ,
|
||||
"TELEGRAM_BOT_TOKEN": self._token(),
|
||||
"TELEGRAM_POLL_TIMEOUT": str(self._poll_timeout()),
|
||||
}
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
WORKER_MODULE,
|
||||
cwd=str(BASE_DIR),
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
limit=STREAM_LIMIT,
|
||||
)
|
||||
self._tasks = [
|
||||
asyncio.create_task(self._read_stdout()),
|
||||
asyncio.create_task(self._read_stderr()),
|
||||
]
|
||||
self.log(f"Telegram worker started (pid {self._proc.pid})")
|
||||
|
||||
async def _terminate(self) -> None:
|
||||
proc = self._proc
|
||||
self._proc = None
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
self._tasks = []
|
||||
for future in self._pending.values():
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
self._pending = {}
|
||||
if proc is None:
|
||||
return
|
||||
try:
|
||||
if proc.stdin is not None and not proc.stdin.is_closing():
|
||||
proc.stdin.close()
|
||||
except Exception: # noqa: BLE001 - closing a dead pipe is fine
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=STOP_GRACE_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
self._kill_group(proc)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=STOP_GRACE_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
self.log("Telegram worker stopped")
|
||||
|
||||
def _kill_group(self, proc: asyncio.subprocess.Process) -> None:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
async def _read_stdout(self) -> None:
|
||||
proc = self._proc
|
||||
if proc is None or proc.stdout is None:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
frame = json.loads(line.decode("utf-8", "replace"))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
await self._handle_frame(frame)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 - reader must not crash the supervisor
|
||||
logger.exception("Telegram stdout reader failed")
|
||||
|
||||
async def _read_stderr(self) -> None:
|
||||
proc = self._proc
|
||||
if proc is None or proc.stderr is None:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
line = await proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
self._live_log("[stderr] " + line.decode("utf-8", "replace").rstrip())
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
async def _handle_frame(self, frame: dict) -> None:
|
||||
kind = frame.get("type")
|
||||
if kind == "log":
|
||||
self._live_log(str(frame.get("line", "")))
|
||||
elif kind == "ready":
|
||||
self._live_log("worker ready")
|
||||
elif kind == "result":
|
||||
self._resolve(frame)
|
||||
elif kind == "message":
|
||||
self._stats["in"] += 1
|
||||
self._live_log(f"in <- chat {frame.get('chat_id')}")
|
||||
if self._bridge is not None:
|
||||
asyncio.create_task(self._dispatch_inbound(frame))
|
||||
|
||||
async def _dispatch_inbound(self, frame: dict) -> None:
|
||||
try:
|
||||
await self._bridge.handle_inbound(frame)
|
||||
except Exception: # noqa: BLE001 - surfaced as a stat, never crashes the reader
|
||||
self._stats["errors"] += 1
|
||||
logger.exception("Telegram inbound handling failed")
|
||||
|
||||
def _resolve(self, frame: dict) -> None:
|
||||
future = self._pending.pop(frame.get("req_id"), None)
|
||||
if future is not None and not future.done():
|
||||
future.set_result(frame)
|
||||
|
||||
def _live_log(self, line: str) -> None:
|
||||
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
entry = f"[{stamp}] {line}"
|
||||
self._live_logs.append(entry)
|
||||
asyncio.create_task(self._publish_log(entry))
|
||||
|
||||
async def _publish_log(self, entry: str) -> None:
|
||||
try:
|
||||
from devplacepy.services.pubsub import publish
|
||||
|
||||
await publish(f"admin.services.{self.name}.logs", {"line": entry})
|
||||
except Exception: # noqa: BLE001 - live logs are best-effort
|
||||
pass
|
||||
|
||||
async def _write(self, command: dict) -> None:
|
||||
proc = self._proc
|
||||
if proc is None or proc.stdin is None or proc.stdin.is_closing():
|
||||
raise RuntimeError("Telegram worker is not running")
|
||||
payload = (json.dumps(command, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
async with self._stdin_lock:
|
||||
proc.stdin.write(payload)
|
||||
await proc.stdin.drain()
|
||||
|
||||
def _next_req(self) -> int:
|
||||
self._req_seq += 1
|
||||
return self._req_seq
|
||||
|
||||
async def send(self, chat_id: int, text: str, parse_mode: str = "HTML") -> int | None:
|
||||
req_id = self._next_req()
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending[req_id] = future
|
||||
try:
|
||||
await self._write(
|
||||
{"cmd": "send", "req_id": req_id, "chat_id": chat_id, "text": text, "parse_mode": parse_mode}
|
||||
)
|
||||
except RuntimeError:
|
||||
self._pending.pop(req_id, None)
|
||||
self._stats["errors"] += 1
|
||||
return None
|
||||
self._stats["out"] += 1
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=RESULT_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
self._pending.pop(req_id, None)
|
||||
self._stats["errors"] += 1
|
||||
return None
|
||||
if not result.get("ok"):
|
||||
self._stats["errors"] += 1
|
||||
return None
|
||||
return result.get("message_id")
|
||||
|
||||
async def edit(self, chat_id: int, message_id: int, text: str, parse_mode: str = "HTML") -> bool:
|
||||
req_id = self._next_req()
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending[req_id] = future
|
||||
try:
|
||||
await self._write(
|
||||
{
|
||||
"cmd": "edit",
|
||||
"req_id": req_id,
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"text": text,
|
||||
"parse_mode": parse_mode,
|
||||
}
|
||||
)
|
||||
except RuntimeError:
|
||||
self._pending.pop(req_id, None)
|
||||
return False
|
||||
self._stats["edits"] += 1
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=RESULT_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
self._pending.pop(req_id, None)
|
||||
return False
|
||||
return bool(result.get("ok"))
|
||||
|
||||
async def chat_action(self, chat_id: int, action: str = "typing") -> None:
|
||||
try:
|
||||
await self._write({"cmd": "chat_action", "chat_id": chat_id, "action": action})
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
async def send_markdown(self, chat_id: int, text: str) -> bool:
|
||||
chunks = split_for_telegram(markdown_to_telegram_html(text))
|
||||
delivered = False
|
||||
for chunk in chunks:
|
||||
if await self.send(chat_id, chunk) is not None:
|
||||
delivered = True
|
||||
return delivered
|
||||
|
||||
def record_latency(self, seconds: float) -> None:
|
||||
self._stats["latencies"].append(seconds)
|
||||
|
||||
def _poll_timeout(self) -> int:
|
||||
return max(1, int(self.get_config().get(FIELD_POLL_TIMEOUT, 25)))
|
||||
|
||||
def _max_concurrent(self) -> int:
|
||||
return max(1, int(self.get_config().get(FIELD_MAX_CONCURRENT, 8)))
|
||||
|
||||
def worker_alive(self) -> bool:
|
||||
return self._alive()
|
||||
|
||||
def collect_metrics(self) -> dict:
|
||||
latencies = list(self._stats["latencies"])
|
||||
avg_ms = int(sum(latencies) / len(latencies) * 1000) if latencies else 0
|
||||
return {
|
||||
"stats": [
|
||||
{"label": "Worker", "value": "running" if self._alive() else "stopped"},
|
||||
{"label": "Messages in", "value": self._stats["in"]},
|
||||
{"label": "Messages out", "value": self._stats["out"]},
|
||||
{"label": "Edits", "value": self._stats["edits"]},
|
||||
{"label": "Errors", "value": self._stats["errors"]},
|
||||
{"label": "Avg response", "value": f"{avg_ms} ms"},
|
||||
{"label": "Token", "value": "set" if self._token() else "missing"},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import get_int_setting, get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
CODE_TTL_SETTING = "telegram_code_ttl_minutes"
|
||||
DEFAULT_CODE_TTL_MINUTES = 60
|
||||
PAIRINGS = "telegram_pairings"
|
||||
LINKS = "telegram_links"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _hash(code: str) -> str:
|
||||
return hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _parse(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _ttl_minutes() -> int:
|
||||
return max(1, get_int_setting(CODE_TTL_SETTING, DEFAULT_CODE_TTL_MINUTES))
|
||||
|
||||
|
||||
def _active_code_hashes() -> set[str]:
|
||||
table = get_table(PAIRINGS)
|
||||
now = _now()
|
||||
active: set[str] = set()
|
||||
for row in table.find(used=0):
|
||||
try:
|
||||
if _parse(row["expires_at"]) > now:
|
||||
active.add(row["code_hash"])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue
|
||||
return active
|
||||
|
||||
|
||||
def _evict_oldest_active() -> None:
|
||||
table = get_table(PAIRINGS)
|
||||
oldest = None
|
||||
for row in table.find(used=0):
|
||||
created = row.get("created_at") or ""
|
||||
if oldest is None or created < (oldest.get("created_at") or ""):
|
||||
oldest = row
|
||||
if oldest is not None:
|
||||
table.update({"id": oldest["id"], "used": 1}, ["id"])
|
||||
|
||||
|
||||
def _unique_code() -> str:
|
||||
for _ in range(2):
|
||||
taken = _active_code_hashes()
|
||||
for _ in range(256):
|
||||
candidate = f"{secrets.randbelow(10000):04d}"
|
||||
if _hash(candidate) not in taken:
|
||||
return candidate
|
||||
_evict_oldest_active()
|
||||
raise RuntimeError("Telegram pairing code pool is exhausted")
|
||||
|
||||
|
||||
def issue_code(user_uid: str) -> dict[str, Any]:
|
||||
table = get_table(PAIRINGS)
|
||||
for row in table.find(user_uid=user_uid, used=0):
|
||||
table.update({"id": row["id"], "used": 1}, ["id"])
|
||||
code = _unique_code()
|
||||
ttl = _ttl_minutes()
|
||||
expires_at = _now() + timedelta(minutes=ttl)
|
||||
table.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"code_hash": _hash(code),
|
||||
"expires_at": _iso(expires_at),
|
||||
"used": 0,
|
||||
"created_at": _iso(_now()),
|
||||
}
|
||||
)
|
||||
return {"code": code, "expires_at": _iso(expires_at), "ttl_minutes": ttl}
|
||||
|
||||
|
||||
def verify_code(code: str, chat_id: int, from_id: int) -> dict[str, Any] | None:
|
||||
cleaned = code.strip()
|
||||
if not (cleaned.isdigit() and len(cleaned) == 4):
|
||||
return None
|
||||
table = get_table(PAIRINGS)
|
||||
match = table.find_one(code_hash=_hash(cleaned), used=0)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
if _parse(match["expires_at"]) < _now():
|
||||
return None
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return None
|
||||
table.update({"id": match["id"], "used": 1}, ["id"])
|
||||
bind(match["user_uid"], chat_id, from_id)
|
||||
users = get_table("users")
|
||||
return users.find_one(uid=match["user_uid"])
|
||||
|
||||
|
||||
def bind(user_uid: str, chat_id: int, from_id: int) -> None:
|
||||
table = get_table(LINKS)
|
||||
for row in table.find(user_uid=user_uid):
|
||||
table.delete(id=row["id"])
|
||||
for row in table.find(chat_id=chat_id):
|
||||
table.delete(id=row["id"])
|
||||
table.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"chat_id": int(chat_id),
|
||||
"from_id": int(from_id),
|
||||
"created_at": _iso(_now()),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def link_for_user(user_uid: str) -> dict[str, Any] | None:
|
||||
return get_table(LINKS).find_one(user_uid=user_uid)
|
||||
|
||||
|
||||
def user_for_chat(chat_id: int) -> dict[str, Any] | None:
|
||||
return get_table(LINKS).find_one(chat_id=int(chat_id))
|
||||
|
||||
|
||||
def unpair(user_uid: str) -> int:
|
||||
table = get_table(LINKS)
|
||||
removed = 0
|
||||
for row in table.find(user_uid=user_uid):
|
||||
table.delete(id=row["id"])
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def is_paired(user_uid: str) -> bool:
|
||||
return link_for_user(user_uid) is not None
|
||||
@@ -0,0 +1,304 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from .backend import HttpTelegramBackend, TelegramBackend
|
||||
|
||||
DEFAULT_POLL_TIMEOUT = 25
|
||||
DEFAULT_IMAGE_MAX_BYTES = 2_000_000
|
||||
DEFAULT_MIN_SEND_INTERVAL = 0.05
|
||||
CONFLICT_BACKOFF_SECONDS = 3.0
|
||||
ERROR_BACKOFF_SECONDS = 2.0
|
||||
_TAG = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _guess_mime(file_path: str) -> str:
|
||||
lowered = file_path.lower()
|
||||
if lowered.endswith(".png"):
|
||||
return "image/png"
|
||||
if lowered.endswith(".webp"):
|
||||
return "image/webp"
|
||||
if lowered.endswith(".gif"):
|
||||
return "image/gif"
|
||||
return "image/jpeg"
|
||||
|
||||
|
||||
def _strip_tags(text: str) -> str:
|
||||
import html as _html
|
||||
|
||||
return _html.unescape(_TAG.sub("", text))
|
||||
|
||||
|
||||
def _entity_error(resp: dict[str, Any]) -> bool:
|
||||
return "can't parse entities" in str(resp.get("description", "")).lower()
|
||||
|
||||
|
||||
class TelegramWorker:
|
||||
def __init__(
|
||||
self,
|
||||
backend: TelegramBackend,
|
||||
on_emit: Callable[[dict[str, Any]], Awaitable[None]],
|
||||
*,
|
||||
poll_timeout: int = DEFAULT_POLL_TIMEOUT,
|
||||
image_max_bytes: int = DEFAULT_IMAGE_MAX_BYTES,
|
||||
min_send_interval: float = DEFAULT_MIN_SEND_INTERVAL,
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._on_emit = on_emit
|
||||
self._poll_timeout = poll_timeout
|
||||
self._image_max_bytes = image_max_bytes
|
||||
self._min_send_interval = min_send_interval
|
||||
self._offset = 0
|
||||
self._stop = asyncio.Event()
|
||||
self._gate = asyncio.Lock()
|
||||
self._last_call = 0.0
|
||||
|
||||
async def _emit(self, frame: dict[str, Any]) -> None:
|
||||
await self._on_emit(frame)
|
||||
|
||||
async def _log(self, line: str) -> None:
|
||||
await self._emit({"type": "log", "line": line})
|
||||
|
||||
async def _throttle(self) -> None:
|
||||
async with self._gate:
|
||||
wait = self._min_send_interval - (time.monotonic() - self._last_call)
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
self._last_call = time.monotonic()
|
||||
|
||||
async def poll_once(self) -> list[dict[str, Any]]:
|
||||
resp = await self._backend.get_updates(self._offset, self._poll_timeout)
|
||||
if not resp.get("ok"):
|
||||
if resp.get("error_code") == 409:
|
||||
await self._log("getUpdates conflict (409); backing off")
|
||||
await asyncio.sleep(CONFLICT_BACKOFF_SECONDS)
|
||||
else:
|
||||
await self._log(f"getUpdates error: {resp.get('description', 'unknown')}")
|
||||
await asyncio.sleep(ERROR_BACKOFF_SECONDS)
|
||||
return []
|
||||
updates = resp.get("result", []) or []
|
||||
for update in updates:
|
||||
self._offset = max(self._offset, int(update.get("update_id", 0)) + 1)
|
||||
return updates
|
||||
|
||||
async def process_update(self, update: dict[str, Any]) -> None:
|
||||
message = update.get("message")
|
||||
if not isinstance(message, dict):
|
||||
return
|
||||
chat = message.get("chat", {})
|
||||
if chat.get("type") != "private":
|
||||
return
|
||||
chat_id = chat.get("id")
|
||||
from_id = (message.get("from") or {}).get("id")
|
||||
if chat_id is None or from_id is None:
|
||||
return
|
||||
text = message.get("text") or message.get("caption") or ""
|
||||
images: list[str] = []
|
||||
photos = message.get("photo")
|
||||
if isinstance(photos, list) and photos:
|
||||
largest = max(photos, key=lambda p: p.get("file_size", 0) or 0)
|
||||
data_uri = await self._fetch_image(largest.get("file_id"))
|
||||
if data_uri:
|
||||
images.append(data_uri)
|
||||
document = message.get("document")
|
||||
if isinstance(document, dict) and str(document.get("mime_type", "")).startswith(
|
||||
"image/"
|
||||
):
|
||||
data_uri = await self._fetch_image(
|
||||
document.get("file_id"), document.get("mime_type")
|
||||
)
|
||||
if data_uri:
|
||||
images.append(data_uri)
|
||||
await self._emit(
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"from_id": from_id,
|
||||
"text": text,
|
||||
"images": images,
|
||||
}
|
||||
)
|
||||
|
||||
async def _fetch_image(self, file_id: str | None, mime: str | None = None) -> str:
|
||||
if not file_id:
|
||||
return ""
|
||||
info = await self._backend.get_file(file_id)
|
||||
if not info.get("ok"):
|
||||
return ""
|
||||
file_path = (info.get("result") or {}).get("file_path", "")
|
||||
if not file_path:
|
||||
return ""
|
||||
data = await self._backend.download(file_path)
|
||||
if not data:
|
||||
return ""
|
||||
if len(data) > self._image_max_bytes:
|
||||
await self._log(f"image skipped ({len(data)} bytes over cap)")
|
||||
return ""
|
||||
resolved_mime = mime or _guess_mime(file_path)
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
return f"data:{resolved_mime};base64,{encoded}"
|
||||
|
||||
async def handle_command(self, command: dict[str, Any]) -> None:
|
||||
kind = command.get("cmd")
|
||||
try:
|
||||
if kind == "send":
|
||||
await self._send(command)
|
||||
elif kind == "edit":
|
||||
await self._edit(command)
|
||||
elif kind == "chat_action":
|
||||
await self._chat_action(command)
|
||||
except Exception as exc: # noqa: BLE001 - one bad command must not kill the worker
|
||||
await self._log(f"command {kind} failed: {exc}")
|
||||
req_id = command.get("req_id")
|
||||
if req_id is not None:
|
||||
await self._emit({"type": "result", "req_id": req_id, "ok": False})
|
||||
|
||||
async def _send(self, command: dict[str, Any]) -> None:
|
||||
chat_id = command["chat_id"]
|
||||
text = str(command.get("text", ""))
|
||||
parse_mode = command.get("parse_mode")
|
||||
await self._throttle()
|
||||
resp = await self._backend.send_message(chat_id, text, parse_mode)
|
||||
resp = await self._retry_if_needed(
|
||||
resp, lambda mode: self._backend.send_message(chat_id, text, mode), parse_mode
|
||||
)
|
||||
await self._emit_result(command.get("req_id"), resp)
|
||||
|
||||
async def _edit(self, command: dict[str, Any]) -> None:
|
||||
chat_id = command["chat_id"]
|
||||
message_id = command["message_id"]
|
||||
text = str(command.get("text", ""))
|
||||
parse_mode = command.get("parse_mode")
|
||||
await self._throttle()
|
||||
resp = await self._backend.edit_message_text(chat_id, message_id, text, parse_mode)
|
||||
resp = await self._retry_if_needed(
|
||||
resp,
|
||||
lambda mode: self._backend.edit_message_text(chat_id, message_id, text, mode),
|
||||
parse_mode,
|
||||
)
|
||||
await self._emit_result(command.get("req_id"), resp)
|
||||
|
||||
async def _chat_action(self, command: dict[str, Any]) -> None:
|
||||
await self._throttle()
|
||||
await self._backend.send_chat_action(command["chat_id"], command.get("action", "typing"))
|
||||
|
||||
async def _retry_if_needed(
|
||||
self,
|
||||
resp: dict[str, Any],
|
||||
again: Callable[[str | None], Awaitable[dict[str, Any]]],
|
||||
parse_mode: str | None,
|
||||
) -> dict[str, Any]:
|
||||
if resp.get("ok"):
|
||||
return resp
|
||||
if resp.get("error_code") == 429:
|
||||
retry_after = int((resp.get("parameters") or {}).get("retry_after", 1))
|
||||
await asyncio.sleep(min(retry_after, 30) + 0.25)
|
||||
await self._throttle()
|
||||
resp = await again(parse_mode)
|
||||
if resp.get("ok"):
|
||||
return resp
|
||||
if parse_mode and _entity_error(resp):
|
||||
await self._throttle()
|
||||
resp = await again(None)
|
||||
return resp
|
||||
|
||||
async def _emit_result(self, req_id: Any, resp: dict[str, Any]) -> None:
|
||||
if req_id is None:
|
||||
if not resp.get("ok"):
|
||||
await self._log(f"send failed: {resp.get('description', 'unknown')}")
|
||||
return
|
||||
message_id = None
|
||||
if resp.get("ok"):
|
||||
message_id = (resp.get("result") or {}).get("message_id")
|
||||
await self._emit(
|
||||
{"type": "result", "req_id": req_id, "ok": bool(resp.get("ok")), "message_id": message_id}
|
||||
)
|
||||
|
||||
async def _read_commands(self) -> None:
|
||||
loop = asyncio.get_event_loop()
|
||||
reader = asyncio.StreamReader()
|
||||
protocol = asyncio.StreamReaderProtocol(reader)
|
||||
await loop.connect_read_pipe(lambda: protocol, sys.stdin)
|
||||
while not self._stop.is_set():
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
break
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
try:
|
||||
command = json.loads(stripped)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
asyncio.create_task(self.handle_command(command))
|
||||
self._stop.set()
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
updates = await self.poll_once()
|
||||
except Exception as exc: # noqa: BLE001 - keep polling through transient failures
|
||||
await self._log(f"poll loop error: {exc}")
|
||||
await asyncio.sleep(ERROR_BACKOFF_SECONDS)
|
||||
continue
|
||||
for update in updates:
|
||||
try:
|
||||
await self.process_update(update)
|
||||
except Exception as exc: # noqa: BLE001 - one bad update never stops the loop
|
||||
await self._log(f"update processing error: {exc}")
|
||||
|
||||
async def run(self) -> None:
|
||||
await self._emit({"type": "ready"})
|
||||
await self._log("Telegram worker started")
|
||||
reader_task = asyncio.create_task(self._read_commands())
|
||||
poll_task = asyncio.create_task(self._poll_loop())
|
||||
await self._stop.wait()
|
||||
poll_task.cancel()
|
||||
reader_task.cancel()
|
||||
await asyncio.gather(poll_task, reader_task, return_exceptions=True)
|
||||
|
||||
|
||||
async def _stdout_emitter() -> Callable[[dict[str, Any]], Awaitable[None]]:
|
||||
lock = asyncio.Lock()
|
||||
|
||||
async def emit(frame: dict[str, Any]) -> None:
|
||||
async with lock:
|
||||
sys.stdout.write(json.dumps(frame, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
return emit
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
|
||||
emit = await _stdout_emitter()
|
||||
if not token:
|
||||
await emit({"type": "log", "line": "TELEGRAM_BOT_TOKEN is not set; exiting"})
|
||||
return
|
||||
poll_timeout = int(os.environ.get("TELEGRAM_POLL_TIMEOUT", DEFAULT_POLL_TIMEOUT))
|
||||
image_cap = int(os.environ.get("TELEGRAM_IMAGE_MAX_BYTES", DEFAULT_IMAGE_MAX_BYTES))
|
||||
backend = HttpTelegramBackend(token)
|
||||
worker = TelegramWorker(
|
||||
backend, emit, poll_timeout=poll_timeout, image_max_bytes=image_cap
|
||||
)
|
||||
await worker.run()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
asyncio.run(_main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1225,3 +1225,31 @@ body:has(.page-messages) {
|
||||
color: var(--text-muted);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.response-time-indicator {
|
||||
position: fixed;
|
||||
left: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
z-index: 50;
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.response-time-indicator:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.response-time-indicator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,10 @@ dp-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
dp-title {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
dp-upload {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -550,29 +550,8 @@ pre.code-pre > .code-gutter {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
pre.code-has-copy {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
pre.code-has-copy > .code-copy-btn {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
z-index: 1;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(40, 44, 52, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
pre.code-has-copy > .code-copy-btn:hover {
|
||||
color: var(--white);
|
||||
border-color: var(--accent, #6366f1);
|
||||
}
|
||||
/* The generic `pre.code-has-copy` copy-button styles moved to markdown.css so they
|
||||
apply to backend-rendered content everywhere, not only on docs pages. */
|
||||
|
||||
/* ---- Docs search ---- */
|
||||
.docs-search-form {
|
||||
|
||||
@@ -150,6 +150,30 @@ dp-content:hover .content-copy-btn,
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
pre.code-has-copy {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
pre.code-has-copy > .code-copy-btn {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
z-index: 1;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(40, 44, 52, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
pre.code-has-copy > .code-copy-btn:hover {
|
||||
color: var(--white);
|
||||
border-color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
.embed-youtube {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { CustomizationToggle } from "./CustomizationToggle.js";
|
||||
import { NotificationPrefs } from "./NotificationPrefs.js";
|
||||
import { AiCorrection } from "./AiCorrection.js";
|
||||
import { AiModifier } from "./AiModifier.js";
|
||||
import { TelegramPairing } from "./TelegramPairing.js";
|
||||
import { AdminNotificationDefaults } from "./AdminNotificationDefaults.js";
|
||||
import { UserAiUsage } from "./UserAiUsage.js";
|
||||
import { Tabs } from "./Tabs.js";
|
||||
@@ -66,6 +67,7 @@ class Application {
|
||||
this.notificationPrefs = new NotificationPrefs();
|
||||
this.aiCorrection = new AiCorrection();
|
||||
this.aiModifier = new AiModifier();
|
||||
this.telegramPairing = new TelegramPairing();
|
||||
this.adminNotificationDefaults = new AdminNotificationDefaults();
|
||||
this.userAiUsage = new UserAiUsage();
|
||||
this.tabs = new Tabs();
|
||||
|
||||
@@ -14,8 +14,9 @@ class BackupMonitor {
|
||||
this.scheduleModal = document.getElementById("backup-schedule-modal");
|
||||
this.scheduleKind = document.getElementById("backup-schedule-kind");
|
||||
this.scheduleTitle = document.getElementById("backup-schedule-title");
|
||||
this.pollMs = 8000;
|
||||
this.pollMs = 30000;
|
||||
this.targets = [];
|
||||
this.canDownload = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -23,12 +24,26 @@ class BackupMonitor {
|
||||
this.bindRunForm();
|
||||
this.bindScheduleForm();
|
||||
this.bindActions();
|
||||
this.subscribe();
|
||||
this.poller = new Poller(() => this.poll(), this.pollMs);
|
||||
}
|
||||
|
||||
subscribe() {
|
||||
const pubsub = window.app && window.app.pubsub;
|
||||
if (!pubsub) return;
|
||||
if (this.unsubscribe) this.unsubscribe();
|
||||
this.unsubscribe = pubsub.subscribe("admin.backups", (data) => this.applyPush(data));
|
||||
}
|
||||
|
||||
applyPush(data) {
|
||||
this.targets = data.targets || this.targets;
|
||||
this.render(data);
|
||||
}
|
||||
|
||||
async poll() {
|
||||
try {
|
||||
const data = await Http.getJson("/admin/backups/data");
|
||||
this.canDownload = !!data.can_download_backups;
|
||||
this.targets = data.targets || [];
|
||||
this.render(data);
|
||||
} catch {
|
||||
@@ -257,9 +272,9 @@ class BackupMonitor {
|
||||
backupActions(backup) {
|
||||
const cell = this.el("td", "backups-actions");
|
||||
if (backup.status === "done") {
|
||||
if (this.canDownload && backup.download_url) {
|
||||
if (this.canDownload) {
|
||||
const link = this.el("a", "admin-btn admin-btn-sm", "Download");
|
||||
link.href = backup.download_url;
|
||||
link.href = `/admin/backups/${backup.uid}/download`;
|
||||
cell.appendChild(link);
|
||||
} else {
|
||||
const blocked = this.el("button", "admin-btn admin-btn-sm", "Download");
|
||||
@@ -329,7 +344,6 @@ class BackupMonitor {
|
||||
}
|
||||
|
||||
render(data) {
|
||||
this.canDownload = !!data.can_download_backups;
|
||||
if (this.generated && data.generated_at) {
|
||||
this.generated.textContent = `Updated ${DateFormat.format(data.generated_at, true)}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { contentRenderer } from "./ContentRenderer.js";
|
||||
import { CodeBlock } from "./CodeBlock.js";
|
||||
import { MentionInput } from "./MentionInput.js";
|
||||
import { EmojiPicker } from "./EmojiPicker.js";
|
||||
|
||||
@@ -20,6 +21,15 @@ export class ContentEnhancer {
|
||||
});
|
||||
});
|
||||
contentRenderer.highlightAll();
|
||||
this.enhanceCodeBlocks();
|
||||
}
|
||||
|
||||
enhanceCodeBlocks(root = document) {
|
||||
root.querySelectorAll(".rendered-content pre").forEach((pre) => {
|
||||
if (pre.querySelector("code")) {
|
||||
CodeBlock.enhance(pre, { lineNumbers: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initEmojiPickers() {
|
||||
|
||||
@@ -70,6 +70,34 @@ export class ContentRenderer {
|
||||
return html;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
renderInline(text) {
|
||||
if (!text) return "";
|
||||
text = this.normalizeDashes(text);
|
||||
text = this.replaceShortcodes(text);
|
||||
|
||||
let html;
|
||||
if (typeof marked !== "undefined" && typeof marked.parseInline === "function") {
|
||||
html = marked.parseInline(text, { gfm: true });
|
||||
} else {
|
||||
html = this.escapeHtml(text);
|
||||
}
|
||||
|
||||
if (typeof DOMPurify === "undefined") {
|
||||
throw new Error("DOMPurify not loaded; refusing to render untrusted HTML");
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ["b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br"],
|
||||
ALLOWED_ATTR: [],
|
||||
});
|
||||
}
|
||||
|
||||
processMedia(html) {
|
||||
const temp = document.createElement("div");
|
||||
temp.innerHTML = html;
|
||||
|
||||
@@ -23,9 +23,22 @@ class ServiceMonitor {
|
||||
if (pubsub) {
|
||||
const topic = this.detail ? `admin.services.${this.detailName}` : "admin.services";
|
||||
pubsub.subscribe(topic, (data) => this.applyData(data));
|
||||
if (this.detail) {
|
||||
pubsub.subscribe(`admin.services.${this.detailName}.logs`, (data) => this.appendLive(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendLive(data) {
|
||||
if (!data || !data.line || !this.detail) return;
|
||||
const pre = this.detail.querySelector("[data-live-log]");
|
||||
if (!pre) return;
|
||||
const lines = pre.textContent === "No live output yet." ? [] : pre.textContent.split("\n");
|
||||
lines.push(data.line);
|
||||
pre.textContent = lines.slice(-300).join("\n");
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
|
||||
applyData(data) {
|
||||
if (!data) return;
|
||||
if (this.detail) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
class TelegramPairing {
|
||||
constructor() {
|
||||
this.root = document.querySelector("[data-telegram-pairing]");
|
||||
if (!this.root) return;
|
||||
this.username = this.root.dataset.username;
|
||||
this.statusLabel = this.root.querySelector("[data-telegram-status]");
|
||||
this.requestBtn = this.root.querySelector("[data-telegram-request]");
|
||||
this.unpairBtn = this.root.querySelector("[data-telegram-unpair]");
|
||||
this.codeBox = this.root.querySelector("[data-telegram-code]");
|
||||
this.codeValue = this.root.querySelector("[data-telegram-code-value]");
|
||||
this.codeHint = this.root.querySelector("[data-telegram-code-hint]");
|
||||
this.message = this.root.querySelector("[data-telegram-status-msg]");
|
||||
if (this.requestBtn) this.requestBtn.addEventListener("click", () => this.request());
|
||||
if (this.unpairBtn) this.unpairBtn.addEventListener("click", () => this.unpair());
|
||||
}
|
||||
|
||||
async request() {
|
||||
this.requestBtn.disabled = true;
|
||||
this.setMessage("Requesting...", "");
|
||||
try {
|
||||
const data = await Http.send(`/profile/${this.username}/telegram`, { action: "request" });
|
||||
this.showCode(data.code, data.ttl_minutes);
|
||||
this.setMessage("Send this code to the bot on Telegram", "success");
|
||||
} catch (error) {
|
||||
this.setMessage("Could not request a code", "error");
|
||||
} finally {
|
||||
this.requestBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async unpair() {
|
||||
this.unpairBtn.disabled = true;
|
||||
this.setMessage("Disconnecting...", "");
|
||||
try {
|
||||
await Http.send(`/profile/${this.username}/telegram`, { action: "unpair" });
|
||||
this.setPaired(false);
|
||||
this.hideCode();
|
||||
this.setMessage("Telegram disconnected", "success");
|
||||
} catch (error) {
|
||||
this.setMessage("Could not disconnect", "error");
|
||||
} finally {
|
||||
this.unpairBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
showCode(code, ttlMinutes) {
|
||||
if (this.codeValue) this.codeValue.textContent = code;
|
||||
if (this.codeHint) this.codeHint.textContent = `Valid for ${ttlMinutes} minutes. Open the Telegram bot and send it this code.`;
|
||||
if (this.codeBox) this.codeBox.hidden = false;
|
||||
}
|
||||
|
||||
hideCode() {
|
||||
if (this.codeBox) this.codeBox.hidden = true;
|
||||
}
|
||||
|
||||
setPaired(paired) {
|
||||
if (this.statusLabel) this.statusLabel.textContent = paired ? "Connected" : "Not connected";
|
||||
if (this.unpairBtn) this.unpairBtn.hidden = !paired;
|
||||
}
|
||||
|
||||
setMessage(text, type) {
|
||||
if (!this.message) return;
|
||||
this.message.textContent = text;
|
||||
this.message.dataset.state = type;
|
||||
}
|
||||
}
|
||||
|
||||
window.TelegramPairing = TelegramPairing;
|
||||
export { TelegramPairing };
|
||||
@@ -0,0 +1,22 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
import { contentRenderer } from "../ContentRenderer.js";
|
||||
|
||||
export class AppTitle extends Component {
|
||||
connectedCallback() {
|
||||
if (this._rendered) {
|
||||
return;
|
||||
}
|
||||
if (typeof DOMPurify === "undefined" || typeof marked === "undefined") {
|
||||
window.addEventListener("load", () => this.connectedCallback(), { once: true });
|
||||
return;
|
||||
}
|
||||
this._rendered = true;
|
||||
this.classList.add("rendered-title");
|
||||
const source = (this.textContent || "").trim();
|
||||
this.innerHTML = contentRenderer.renderInline(source);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-title", AppTitle);
|
||||
@@ -3,6 +3,7 @@
|
||||
import "./AppAvatar.js";
|
||||
import "./AppCode.js";
|
||||
import "./AppContent.js";
|
||||
import "./AppTitle.js";
|
||||
import "./AppUpload.js";
|
||||
import "./AppToast.js";
|
||||
import "./AppDialog.js";
|
||||
|
||||
@@ -107,10 +107,14 @@ export default class DeviiTerminalElement extends FloatingWindow {
|
||||
this._reportVisibility();
|
||||
});
|
||||
this.socket = new DeviiSocket({
|
||||
onOpen: () => this._reportVisibility(),
|
||||
onOpen: () => {
|
||||
this._reportVisibility();
|
||||
this._reportClientInfo();
|
||||
},
|
||||
onReady: () => {
|
||||
this._notice("connected.");
|
||||
this._reportVisibility();
|
||||
this._reportClientInfo();
|
||||
},
|
||||
onClose: () => this._notice("disconnected, reconnecting..."),
|
||||
onMessage: (message) => this._onMessage(message),
|
||||
@@ -643,6 +647,21 @@ export default class DeviiTerminalElement extends FloatingWindow {
|
||||
});
|
||||
}
|
||||
|
||||
_reportClientInfo() {
|
||||
if (!this.socket) return;
|
||||
let timezone = "";
|
||||
try {
|
||||
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
|
||||
} catch (error) {
|
||||
timezone = "";
|
||||
}
|
||||
this.socket.send({
|
||||
type: "clientinfo",
|
||||
timezone,
|
||||
offset: -new Date().getTimezoneOffset(),
|
||||
});
|
||||
}
|
||||
|
||||
async _clientRequest(message) {
|
||||
let result;
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %}
|
||||
<span class="comment-time">{{ dt_ago(item.comment.get('created_at')) if item.comment.get('created_at') else item.time_ago }}</span>
|
||||
</div>
|
||||
<div class="comment-text rendered-content" data-render data-raw="{{ item.comment['content'] }}">{{ item.comment['content'] }}</div>
|
||||
<div class="comment-text rendered-content" data-raw="{{ item.comment['content'] }}">{{ render_content(item.comment['content']) }}</div>
|
||||
{% set attachments = item.get('attachments', []) %}
|
||||
{% if attachments %}
|
||||
{% include "_attachment_display.html" %}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
{% if item.post.get('title') %}
|
||||
<a href="{{ content_url(item.post, 'posts') }}" class="post-title-link">
|
||||
<h3 class="post-title">{{ item.post['title'] }}</h3>
|
||||
<h3 class="post-title">{{ render_title(item.post['title']) }}</h3>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="post-content rendered-content" data-render data-card-href="{{ content_url(item.post, 'posts') }}">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
|
||||
<div class="post-content rendered-content" data-card-href="{{ content_url(item.post, 'posts') }}">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else '')) }}</div>
|
||||
|
||||
{% if item.attachments %}
|
||||
{% include "_attachment_display.html" %}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ item.article['url'] }}" target="_blank" rel="noopener" class="admin-news-title">
|
||||
{{ item.article['title'][:80] }}{% if item.article['title']|length > 80 %}...{% endif %}
|
||||
{{ render_title(item.article['title'][:80] ~ ('...' if item.article['title']|length > 80 else '')) }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ item.article['source_name'] }}</td>
|
||||
|
||||
@@ -193,6 +193,9 @@
|
||||
</footer>
|
||||
{% endblock %}
|
||||
|
||||
{%- set _response_time = response_time_ms(request) -%}
|
||||
{%- if _response_time %}<div class="response-time-indicator" title="Server response time">{{ _response_time }} ms</div>{% endif -%}
|
||||
|
||||
{% if user %}
|
||||
<template id="comment-reply-template">
|
||||
{% set _comment_target_uid = "" %}{% set _comment_target_type = "" %}{% include "_comment_form.html" %}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
data-slug="{{ project['slug'] or project['uid'] }}"
|
||||
data-title="{{ project['title'] }}">
|
||||
<div class="cm-header">
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}" class="back-link">← {{ project['title'] }}</a>
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}" class="back-link">← {{ render_title(project['title']) }}</a>
|
||||
<a href="/admin/containers" class="cm-header-link">All containers →</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ Every component **renders into the light DOM (no shadow root)** so the global CS
|
||||
|---------|---------|
|
||||
| `dp-avatar` | Renders a user avatar. |
|
||||
| `dp-code` | Renders a syntax-highlighted code block. |
|
||||
| `dp-content` | Wraps the content renderer behind `data-render`. |
|
||||
| `dp-content` | Client-side content renderer for live content; server content uses the backend `render_content` Jinja global. |
|
||||
| `dp-upload` | The single file-upload button used everywhere. |
|
||||
| `dp-toast` | Transient notifications (`app.toast`). |
|
||||
| `dp-dialog` | Confirm / prompt / alert modals (`app.dialog`). |
|
||||
@@ -48,6 +48,8 @@ Every component **renders into the light DOM (no shadow root)** so the global CS
|
||||
|
||||
**The component-vs-module rule.** Self-contained presentational widgets become components. Partial-bound controllers that enhance server-rendered markup (votes, reactions, comments) and pure utilities stay plain modules, because they augment existing HTML rather than render standalone UI.
|
||||
|
||||
**Content and titles render on the backend.** For SEO, all content and titles that exist at request time are rendered server-side by the `render_content` and `render_title` Jinja globals (`devplacepy/rendering.py`, built on mistune with full feature parity to the client renderer: emoji shortcodes, markdown, sanitisation, and YouTube/video/audio/image embeds, mentions, and autolinks). The `dp-content`/`dp-title` client components and the `data-render` attribute are now reserved for genuinely live content that is generated in the browser after load (chat answers, newly posted comments, live direct messages, the planning report). Server-rendered code blocks are still syntax-highlighted client-side, and content images are wired to the lightbox via a delegated listener.
|
||||
|
||||
## Shared utilities
|
||||
|
||||
A few plain modules own the cross-cutting patterns. Reach for these instead of hand-rolling a fetch, an interval, or a click handler:
|
||||
|
||||
@@ -30,8 +30,8 @@ All fields are edited on `/admin/services/bots`, grouped into sections the admin
|
||||
| `bot_api_url` | internal gateway | OpenAI-compatible chat-completions endpoint. |
|
||||
| `bot_model` | `molodetz` | Model name sent to the API. |
|
||||
| `bot_api_key` | gateway internal key | Secret; defaults to the local gateway key. |
|
||||
| `bot_input_cost_per_1m` | 0.27 | Input price per 1M tokens, for live spend. |
|
||||
| `bot_output_cost_per_1m` | 1.10 | Output price per 1M tokens, for live spend. |
|
||||
| `bot_input_cost_per_1m` | 0.14 | Fallback input price per 1M tokens; live spend is read from the gateway cost headers. |
|
||||
| `bot_output_cost_per_1m` | 0.28 | Fallback output price per 1M tokens; live spend is read from the gateway cost headers. |
|
||||
|
||||
**Behavior**
|
||||
|
||||
@@ -71,7 +71,7 @@ The fleet publishes live metrics via `collect_metrics()` (shown on the Services
|
||||
- **Stats:** bots running, fleet cost, observed window, cost rate, projected 24h cost, LLM calls, tokens in, tokens out, posts, comments, votes.
|
||||
- **Table** (one row per bot): slot, username, persona, status, posts, comments, votes, LLM calls, cost.
|
||||
|
||||
`Fleet cost` is an estimate computed from token usage and the configured per-1M input and output prices, not the gateway's recorded spend. It is cumulative across restarts because each bot seeds its `LLMClient` totals from its saved state. `Cost rate` and `Projected 24h cost` come from a sliding window, not lifetime: each tick appends `(now, total_cost)`, samples older than `COST_WINDOW_SECONDS` (600s) are evicted, and the rate is the cost delta over the retained span. The projection is shown only after the window reaches `COST_WARMUP_SECONDS` (120s); before that both read "warming up". Samples reset when the service start time changes, so the boot-burst of all bots ramping at once does not dominate a 24h projection extrapolated from a few minutes.
|
||||
`Fleet cost` is read from the gateway's authoritative `X-Gateway-Cost-USD` per-call header (cache- and native-cost-aware), falling back to the configured per-1M prices only when the endpoint returns no gateway cost headers. It is cumulative across restarts because each bot seeds its `LLMClient` totals from its saved state. `Cost rate` and `Projected 24h cost` come from a sliding window, not lifetime: each tick appends `(now, total_cost)`, samples older than `COST_WINDOW_SECONDS` (600s) are evicted, and the rate is the cost delta over the retained span. The projection is shown only after the window reaches `COST_WARMUP_SECONDS` (120s); before that both read "warming up". Samples reset when the service start time changes, so the boot-burst of all bots ramping at once does not dominate a 24h projection extrapolated from a few minutes.
|
||||
|
||||
## State and files
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@ Renders user-supplied text as safe rich content: emoji shortcodes, GitHub-flavor
|
||||
autolinks. It exposes the shared `contentRenderer` engine (`static/js/ContentRenderer.js`),
|
||||
the same pipeline used by the site's `data-render` attribute, as a custom element.
|
||||
|
||||
> Server-rendered content is now produced on the **backend** for SEO (the `render_content`
|
||||
> Jinja global, `devplacepy/rendering.py`, full feature parity built on mistune). This client
|
||||
> component is retained for **live, client-generated content only** - for example the DeepSearch
|
||||
> chat answers and the planning report, which do not exist when the page is first rendered. Do not
|
||||
> use it for content that is known at request time; call `render_content` in the template instead.
|
||||
|
||||
Source: `static/js/components/AppContent.js`.
|
||||
|
||||
## Behaviour
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<div class="docs-content" data-render>
|
||||
# dp-title
|
||||
|
||||
Renders a single-line **title** as safe inline rich text: emoji shortcodes, inline
|
||||
GitHub-flavored markdown (bold, italic, inline code, strikethrough), and `DOMPurify`
|
||||
sanitisation. It is the title-sized companion to [dp-content](/docs/component-dp-content.html):
|
||||
both expose the shared `contentRenderer` engine (`static/js/ContentRenderer.js`), but `dp-title`
|
||||
calls its `renderInline` path, so it never produces block elements, links, images, or media embeds
|
||||
and stays on one line.
|
||||
|
||||
> Server-rendered titles are now produced on the **backend** for SEO (the `render_title` Jinja
|
||||
> global, `devplacepy/rendering.py`, same inline behaviour). This client component is retained for
|
||||
> **live, client-generated** titles only and for future use; do not wrap server-known titles in it.
|
||||
|
||||
Source: `static/js/components/AppTitle.js`.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- On connection the element reads its own text, renders it once, and replaces its contents with
|
||||
the sanitised inline HTML.
|
||||
- The renderer is inline-only: `marked.parseInline` is used (not the block parser), and
|
||||
sanitisation restricts output to inline formatting tags (`b`, `strong`, `i`, `em`, `code`,
|
||||
`del`, `s`, `mark`, `sub`, `sup`, `span`, `br`). Anchors, images, and block tags are stripped,
|
||||
so a `dp-title` placed inside an existing link or heading never produces nested or block markup.
|
||||
- Sanitisation is fail-closed: rendering throws if `DOMPurify` is unavailable rather than emitting
|
||||
unsanitised HTML.
|
||||
- The element is `display: inline`, so it drops in directly inside an `h1`/`h3`, an anchor, or a
|
||||
span without changing layout.
|
||||
- The server still emits the raw title text as the element's content, so no-JS clients see the
|
||||
title as plain text (only the emoji/markdown enhancement is client-side).
|
||||
|
||||
## Where it is used
|
||||
|
||||
Server-rendered titles (posts, projects, gists, news, issues, profile tabs, listings, saved,
|
||||
leaderboard, back-links) are rendered on the backend via the `render_title` Jinja global. This
|
||||
client component is used only for titles created live in the browser and is available for future
|
||||
client-side use.
|
||||
|
||||
## Usage
|
||||
|
||||
Put the title text as the element's content. Escape any literal HTML you do not want treated as
|
||||
live markup:
|
||||
|
||||
```html
|
||||
<h3 class="post-title"><dp-title>Shipped **v2** today :rocket:</dp-title></h3>
|
||||
```
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example</div>
|
||||
<h3><dp-title>Shipping **dp-title** everywhere :rocket: :fire:</dp-title></h3>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "{{ static_url('/static/js/components/AppTitle.js') }}";
|
||||
</script>
|
||||
@@ -25,7 +25,8 @@ is available everywhere, including these documentation pages.
|
||||
|---|---|
|
||||
| [dp-avatar](/docs/component-dp-avatar.html) | User avatar image from a username seed. |
|
||||
| [dp-code](/docs/component-dp-code.html) | Syntax-highlighted code block with copy button. |
|
||||
| [dp-content](/docs/component-dp-content.html) | Render markdown/emoji/media as safe sanitised HTML. |
|
||||
| [dp-content](/docs/component-dp-content.html) | Render markdown/emoji/media as safe sanitised HTML (client-side; live content only - server content uses the backend `render_content`). |
|
||||
| [dp-title](/docs/component-dp-title.html) | Render a single-line title as safe inline emoji/markdown (client-side; live titles only - server titles use the backend `render_title`). |
|
||||
| [dp-upload](/docs/component-dp-upload.html) | Clean file upload button with count and removable chips. |
|
||||
| [dp-toast](/docs/component-dp-toast.html) | Transient corner notifications. |
|
||||
| [dp-dialog](/docs/component-dp-dialog.html) | Promise-based confirm, prompt, and alert modal. |
|
||||
|
||||
@@ -10,7 +10,8 @@ This is the operational companion to the technical reference under [Tools and sc
|
||||
## How Devii operates as an admin
|
||||
|
||||
- Devii authenticates every action with **your own API key**, so all of its work rolls up under your uid and is attributable to you.
|
||||
- Admin-only tools (analytics, settings, user management, backups, gateway routing, the read-only database API) are withheld from non-admin sessions and exposed only because the server confirmed you are an administrator. You never have to ask for elevated access.
|
||||
- Admin-only tools (analytics, settings, user management, backups, gateway routing) are withheld from non-admin sessions and exposed only because the server confirmed you are an administrator. You never have to ask for elevated access.
|
||||
- The read-only **database API** tools are stricter still: they are added to your tool list only when you are the **primary administrator** (the oldest Admin account, the same identity that may download backups). Every other administrator's Devii does not see them and is not aware the database API exists.
|
||||
- **Every action Devii takes is audited** with `origin=devii`, so you can always reconstruct what it did and when.
|
||||
- **Financial figures are admin-only.** Members and guests see only a percentage of their daily AI quota; you see token counts, dollar cost, and projected spend.
|
||||
- Devii never invents routes. When unsure it searches the docs first, then calls a real, audited endpoint - the same one the web UI uses.
|
||||
@@ -38,7 +39,7 @@ Devii will list the matches, wait for your go-ahead, then delete each post and r
|
||||
|
||||
## Finding the items to act on (read-only database)
|
||||
|
||||
Before you can act in bulk you usually need to find the rows. Devii has a **strictly read-only** database surface over every table: `db_list_tables`, `db_list_rows`, `db_query` (SELECT-only - inserts, updates, deletes, and DDL are rejected), and `db_design_query` (natural language to a validated SELECT, which automatically adds `deleted_at IS NULL` for soft-delete tables).
|
||||
Before you can act in bulk you usually need to find the rows. If you are the **primary administrator**, Devii has a **strictly read-only** database surface over every table: `db_list_tables`, `db_list_rows`, `db_query` (SELECT-only - inserts, updates, deletes, and DDL are rejected), and `db_design_query` (natural language to a validated SELECT, which automatically adds `deleted_at IS NULL` for soft-delete tables). These tools are absent for every other administrator.
|
||||
|
||||
When to use it: scoping a moderation sweep, investigating a user, or answering a data question. The database API can never modify data, so this step is always safe.
|
||||
|
||||
|
||||
@@ -43,12 +43,28 @@ only). On reconnect (or after a restart) the saved messages are loaded back into
|
||||
signed-in conversation survives reloads and reboots. Guests are never persisted. See
|
||||
[Data and persistence](/docs/devii-data.html).
|
||||
|
||||
## Autonomous scheduler
|
||||
|
||||
A session's scheduler ticks once a second and runs any due task in `devii_tasks` through the session
|
||||
executor (the result is broadcast to connected tabs and buffered when none are). The scheduler is no
|
||||
longer tied to an open websocket: it is started by `ensure_scheduler_started()`, which runs both on
|
||||
`attach` and from the lock-owner `DeviiService` housekeeping pass. That pass scans `devii_tasks` for
|
||||
every signed-in owner with an enabled pending task, resolves the user (API key, timezone, admin
|
||||
flag), and recreates a headless session whose scheduler runs the task. So a queued reminder fires
|
||||
after a server restart even if the user never reopens Devii, and an idle session that still owns a
|
||||
pending task is not garbage-collected between ticks. Guest tasks are in-memory and run only while the
|
||||
guest stays connected. When a task created as a reminder finishes, the owner also receives a
|
||||
`reminder` in-app notification and live toast. Timezone for the conversion to UTC comes from the
|
||||
browser (sent on connect, persisted to `users.timezone`) and is surfaced to the agent in a
|
||||
`# CURRENT TIME` block.
|
||||
|
||||
## Multi-worker model
|
||||
|
||||
`make prod` runs multiple uvicorn workers, but only one holds the background-service lock. Because
|
||||
hubs are per-process, `/devii/ws` is served **only** by the lock-owning worker
|
||||
(`service_manager.owns_lock()`); other workers close the socket with code 1013 and the
|
||||
auto-reconnecting client lands on the owner. This guarantees a single hub, one broadcast source, and
|
||||
(`service_manager.owns_lock()`); other workers close the socket with code 4013 (a silent fast-retry
|
||||
in the private range) and the auto-reconnecting client lands on the owner, while the disabled-service
|
||||
path closes with the standard 1013. This guarantees a single hub, one broadcast source, and
|
||||
one cost tracker per user. The 24h spend cap is always read from the ledger DB, so it stays correct
|
||||
regardless. See [Multi-worker and concurrency](/docs/production-concurrency.html) for the underlying
|
||||
shared service-lock model.
|
||||
@@ -85,10 +101,12 @@ the client.
|
||||
Cancellation is bulletproof by construction. Tool calls run as awaited coroutines (the react loop
|
||||
gathers them; sub-agents are awaited inline), so cancelling the turn task propagates `CancelledError`
|
||||
through every await with no orphaned work. `cancel_turns` also increments a `_turn_epoch`; a turn
|
||||
only emits its reply or records its turn while its captured epoch still matches the session's, so a
|
||||
turn that briefly survives cancellation, or finishes the instant after an interrupt, can never
|
||||
deliver output or persist anything afterward. A cancelled turn never runs `_record_turn`, so it
|
||||
cannot save a half-finished message list. `stop` then repairs the kept history (`_repair_history`
|
||||
trims any trailing assistant tool-call block whose tool responses are incomplete) so the next turn
|
||||
always starts from a valid conversation.
|
||||
only emits its reply or saves the conversation while its captured epoch still matches the session's,
|
||||
so a turn that briefly survives cancellation, or finishes the instant after an interrupt, can never
|
||||
deliver output or persist a half-finished message list afterward (`_persist_history` runs only on the
|
||||
matching-epoch, non-cancelled path). Spend is accounted separately: `_record_spend` always writes the
|
||||
usage delta actually billed to the ledger, even for a cancelled turn, so an interrupted turn is
|
||||
counted against the 24h cap rather than forgiven. `stop` then repairs the kept history
|
||||
(`_repair_history` trims any trailing assistant tool-call block whose tool responses are incomplete)
|
||||
so the next turn always starts from a valid conversation.
|
||||
</div>
|
||||
|
||||
@@ -35,8 +35,13 @@ Devii runs as `DeviiService` (a background service) and is configured on
|
||||
- `devii_rsearch_url` - base URL of the rsearch-compatible service those tools call.
|
||||
- `devii_rsearch_timeout` - read timeout in seconds for the `rsearch_*` tools (default 300, minimum five minutes). Web-grounded answers can take several minutes, so it is separate from the fetch timeout.
|
||||
|
||||
**Email**
|
||||
- `devii_email_enabled` - enable the `email_*` tools (configure accounts, list/read/search/flag/move/delete messages, send mail) for signed-in users; when off, they are refused. They connect to the user's own external mailbox over IMAP/SMTP, not this platform.
|
||||
- `devii_email_timeout` - connection/read timeout in seconds for IMAP and SMTP calls (default 30).
|
||||
|
||||
Each field falls back to its `DEVII_*` environment variable where applicable
|
||||
(`DEVII_RSEARCH_ENABLED`, `DEVII_RSEARCH_URL`, `DEVII_RSEARCH_TIMEOUT`). The service exposes
|
||||
(`DEVII_RSEARCH_ENABLED`, `DEVII_RSEARCH_URL`, `DEVII_RSEARCH_TIMEOUT`, `DEVII_EMAIL_ENABLED`,
|
||||
`DEVII_EMAIL_TIMEOUT`). The service exposes
|
||||
live metrics (active sessions, connections, 24h spend, caps, model) via `collect_metrics`, shown on
|
||||
its services card.
|
||||
|
||||
|
||||
@@ -11,17 +11,22 @@ also [Architecture and sessions](/docs/devii-architecture.html) and [Tools and s
|
||||
| `devii_conversations` | `(owner_kind, owner_id)` | The full message list (JSON) per owner. Upserted after each turn for **users only**; loaded back on reconnect so a conversation survives reloads and restarts. |
|
||||
| `devii_usage_ledger` | `(owner_kind, owner_id, created_at)` | One row per turn: token counts, `cost_usd`, model. The **authoritative source** for the rolling 24h spend cap. |
|
||||
| `devii_turns` | `(owner_kind, owner_id, started_at)` | Per-turn audit: truncated prompt and reply, iterations, tool-call count, cost, error. |
|
||||
| `devii_tasks` | `(owner_kind, owner_id)` | Autonomous tasks (schedule, status, next run). Owner-scoped. |
|
||||
| `devii_tasks` | `(owner_kind, owner_id)` | Autonomous tasks and reminders (schedule, status, next run, plus a `notify` flag and captured `tz`). Owner-scoped. A signed-in user's rows are run by the lock-owner service, so reminders survive restarts. |
|
||||
| `devii_lessons` | `(owner_kind, owner_id)` | The self-learning memory: observation, conclusion, next action, tags. BM25-searched, owner-isolated. |
|
||||
| `email_accounts` | `(owner_kind, owner_id, label)` | IMAP/SMTP connection settings the `email_*` tools use, one row per named account. Soft-deletable; the password is stored as written and never returned by a tool. Signed-in users only. |
|
||||
|
||||
Indexes for these are created idempotently in `database.init_db()`.
|
||||
|
||||
## Persistent vs ephemeral
|
||||
|
||||
- **Signed-in users:** conversations, tasks, and lessons persist in the main DB, survive
|
||||
restarts, and rehydrate on reconnect.
|
||||
restarts, and rehydrate on reconnect. A pending task or reminder is re-armed at boot by the
|
||||
lock-owner service and fires even if the user never reopens Devii; a `notify` reminder then raises
|
||||
a `reminder` notification and toast. The user's browser timezone is persisted to `users.timezone`
|
||||
for correct wall-clock-to-UTC scheduling.
|
||||
- **Guests:** tasks and lessons use a fresh **in-memory** database per session, never written to
|
||||
disk; conversations are not persisted at all. State is gone when the guest session ends.
|
||||
disk; conversations are not persisted at all. State is gone when the guest session ends, so a
|
||||
guest reminder runs only while that session stays connected and never sends a notification.
|
||||
|
||||
## Owner isolation
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@ system prompt forbids storing credentials, passwords, API keys, or secrets in a
|
||||
## Spending limits
|
||||
|
||||
Spend is capped per owner over a rolling 24 hours, read from `devii_usage_ledger` (the authoritative
|
||||
source). The admin-configurable caps are `devii_user_daily_usd` and `devii_guest_daily_usd`. The cap
|
||||
is checked before each turn; a turn already over the limit is refused. Pricing is configurable
|
||||
(`devii_price_*`) and applied per turn.
|
||||
source). The admin-configurable caps are `devii_user_daily_usd`, `devii_guest_daily_usd`, and
|
||||
`devii_admin_daily_usd` (default `0` = unlimited, so administrators are exempt by default). The cap
|
||||
is checked before each turn; a turn already over the limit is refused. The spend from scheduled-task
|
||||
runs and from cancelled or interrupted turns is recorded to the ledger and counted against the cap,
|
||||
not forgiven. Pricing is configurable (`devii_price_*`) and applied per turn.
|
||||
|
||||
## JavaScript execution toggle
|
||||
|
||||
@@ -40,6 +42,18 @@ radius of a prompt-injected page. `http_request` (arbitrary external API calls)
|
||||
forged into a request against internal addresses. `attach_url` runs the downloaded bytes through the
|
||||
normal upload validation (allowed types, size limit) before storing them.
|
||||
|
||||
## Email
|
||||
|
||||
The `email_*` tools are restricted to signed-in users (never guests) and gated by
|
||||
`devii_email_enabled`. Each IMAP/SMTP host is resolved and checked against the same SSRF guard
|
||||
(private, loopback, link-local addresses refused) before any connection, so a saved account cannot
|
||||
be pointed at an internal service to probe the network. Credentials live per owner in
|
||||
`email_accounts`, scoped to `(owner_kind, owner_id)` like every other Devii store; a tool never
|
||||
returns the stored password (it reports only whether one is set). Sending mail (`email_send`) is a
|
||||
real outbound action the system prompt tells Devii to confirm with the user first, and deleting a
|
||||
message (`email_delete_message`) or removing a saved account (`email_account_delete`) is
|
||||
confirmation-gated by the dispatcher.
|
||||
|
||||
## Confidentiality
|
||||
|
||||
The system prompt instructs Devii never to disclose the underlying AI model, provider, inference
|
||||
|
||||
@@ -15,15 +15,18 @@ across sixteen handlers.
|
||||
## How tools are declared
|
||||
|
||||
`actions/spec.py` defines `Action` (name, method, path, params, `requires_auth`, `requires_admin`,
|
||||
`handler`) and `Catalog`. `registry.py` unions the per-category tuples into `CATALOG`.
|
||||
`requires_primary_admin`, `handler`) and `Catalog`. `registry.py` unions the per-category tuples into `CATALOG`.
|
||||
`tool_schema()` projects each action into the JSON tool schema sent to the model;
|
||||
`tool_schemas_for(authenticated, is_admin)` is the **scope filter**: guests only receive actions
|
||||
where `requires_auth` is false, and `requires_admin` actions are offered only to administrators, so
|
||||
account-operating and admin-only tools are never presented to a guest or member.
|
||||
`tool_schemas_for(authenticated, is_admin, is_primary_admin)` is the **scope filter**: guests only receive actions
|
||||
where `requires_auth` is false, `requires_admin` actions are offered only to administrators, and
|
||||
`requires_primary_admin` actions (the read-only database API tools) are offered only to the **primary
|
||||
administrator** (the oldest Admin account), so account-operating, admin-only, and primary-admin-only
|
||||
tools are never presented to a guest, member, or junior admin.
|
||||
|
||||
**Scope legend** used throughout this page: **public** = offered to everyone including guests;
|
||||
**auth** = signed-in members (and the account-operating actions run as that user); **admin** =
|
||||
administrators only, gated twice (schema withheld and dispatcher refusal).
|
||||
administrators only, gated twice (schema withheld and dispatcher refusal); **primary admin** = the
|
||||
oldest Admin account only (the database API tools), gated the same way.
|
||||
|
||||
## Handler categories (scopes)
|
||||
|
||||
@@ -43,6 +46,7 @@ administrators only, gated twice (schema withheld and dispatcher refusal).
|
||||
| `avatar` | on-screen | The `avatar_*` tools (md-clippy character) - web only. |
|
||||
| `client` | the browser | `get_page_context`, `run_js`, navigation, highlights, toasts, and `open_terminal` - run in the user's own browser; `run_js` is gated by `devii_allow_eval`. |
|
||||
| `container` | Docker (admin) | The `container_*` tools manage supervised container instances on the shared `ppy` image - admin only. |
|
||||
| `email` | external mailbox | The `email_*` tools connect to the user's OWN external mailbox over IMAP/SMTP (Python stdlib, run off-thread), not this platform. Signed-in users only; gated by `devii_email_enabled`. Per-owner credentials live in `email_accounts` and the mail host is SSRF-guarded. |
|
||||
|
||||
## Complete action reference
|
||||
|
||||
@@ -238,6 +242,17 @@ self-evaluation cannot recurse without bound.
|
||||
| `delete_task` | public | Delete a queued task. |
|
||||
| `run_task_now` | public | Trigger a task to execute immediately on the next scheduler tick. |
|
||||
|
||||
These tools are `public` (no sign-in required), but persistence differs by owner. A **signed-in
|
||||
user's** tasks are stored in `devii_tasks` and are run by the lock-owner `DeviiService` itself, so a
|
||||
queued reminder survives a server restart and fires even with no terminal open. A **guest's** tasks
|
||||
live in an in-memory store and run only while the session stays connected. `create_task` and
|
||||
`update_task` take a `notify` flag: when a task with `notify=true` fires, the owner is sent a
|
||||
`reminder` in-app notification and a live toast carrying the result, so reminders reach a signed-in
|
||||
user even when Devii is closed (guests get no notification). Times in task records are UTC; the
|
||||
agent's system prompt carries a `# CURRENT TIME` block with the current UTC time plus the user's
|
||||
local time and timezone (captured from the browser, persisted to `users.timezone`), so wall-clock
|
||||
requests convert to a correct `run_at` while relative requests use `delay_seconds`.
|
||||
|
||||
### Accounting and large results (`cost`, `chunks`)
|
||||
|
||||
| Tool | Scope | What it does |
|
||||
@@ -246,6 +261,31 @@ self-evaluation cannot recurse without bound.
|
||||
| `cost_stats` | admin | Report token usage and full USD cost statistics for the session. |
|
||||
| `read_more` | public | Read the next part of a previously truncated tool result. |
|
||||
|
||||
### Email (`email`)
|
||||
|
||||
The `email_*` tools reach the user's OWN external mailbox over IMAP/SMTP, not this platform, and
|
||||
are available to signed-in users only (never guests). An account must be configured with
|
||||
`email_account_set` under a label first; every other tool takes that label as its `account`.
|
||||
Credentials are stored per owner in `email_accounts` and the password is never returned. The mail
|
||||
host is checked against the SSRF guard before each connect. `email_send` is a real outbound action,
|
||||
and `email_delete_message` and `email_account_delete` are confirmation-gated.
|
||||
|
||||
| Tool | Scope | What it does |
|
||||
|---|---|---|
|
||||
| `email_accounts_list` | auth | List the user's configured email accounts (passwords masked). |
|
||||
| `email_account_get` | auth | Read one account's connection settings (password masked). |
|
||||
| `email_account_set` | auth | Create or update an IMAP/SMTP account connection (sensible defaults applied). |
|
||||
| `email_account_delete` | auth | Delete a saved account connection (confirmation required). |
|
||||
| `email_list_folders` | auth | List the mailbox folders on the account. |
|
||||
| `email_list_messages` | auth | List messages in a folder, newest first, with optional filters and paging. |
|
||||
| `email_search` | auth | Search a folder by sender, subject, date, text, or read/flag state. |
|
||||
| `email_read_message` | auth | Read a message's full body, headers, and attachment list. |
|
||||
| `email_mark` | auth | Mark a message read, unread, flagged, or unflagged. |
|
||||
| `email_set_flags` | auth | Add or remove arbitrary IMAP flags on a message. |
|
||||
| `email_move_message` | auth | Move a message to another folder. |
|
||||
| `email_delete_message` | auth | Delete a message, optionally to a Trash folder (confirmation required). |
|
||||
| `email_send` | auth | Send an email via the account's SMTP server. |
|
||||
|
||||
### Customization (`customization`)
|
||||
|
||||
| Tool | Scope | What it does |
|
||||
|
||||
@@ -23,6 +23,7 @@ the platform - then verifies them for you.
|
||||
- **Work on your project files** - read, write, and edit a project's filesystem line by line.
|
||||
- **Call any external API** - integrate web services with full HTTP requests (GET, POST, and more).
|
||||
- **Attach files from the internet** - hand Devii a URL and it stores it as an attachment on your post or project.
|
||||
- **Read and send your email** - connect your own mailbox over IMAP/SMTP and have Devii list, search, read, organise, and send mail for you.
|
||||
- **Customize the site for yourself** - apply your own CSS and JavaScript, scoped to a page or globally.
|
||||
- **Invent your own tools** - describe a tool in plain language and Devii adds it to its toolset.
|
||||
- **Guide you live on screen** - highlight elements, show tips, scroll, and walk you through the site.
|
||||
@@ -65,6 +66,23 @@ Devii can schedule work and run it for you on a cadence you choose.
|
||||
|
||||
> Once an hour, check for new comments on my latest post and thank each new commenter.
|
||||
|
||||
### Reminders
|
||||
|
||||
Ask Devii to remind you and it schedules the reminder instead of answering right away.
|
||||
|
||||
> Remind me to go upstairs in 40 seconds.
|
||||
|
||||
> Tell me to take a break every day at 15:00.
|
||||
|
||||
Reminders are **timezone-aware**: a wall-clock time like "3pm" is read in your own timezone (your
|
||||
browser tells Devii which one), so you never do the maths. They are **persistent**: a queued
|
||||
reminder is stored and runs on the server, so it still fires after a server restart and even when
|
||||
the Devii window is closed. When a reminder fires you get an in-app notification and a live toast
|
||||
with its message, on top of seeing it in the terminal. You control the **Reminders** notification
|
||||
like any other on your profile. Ask Devii to list, change, run now, or delete your reminders at any
|
||||
time. Reminders that survive restarts and notify you require being signed in; a guest session can
|
||||
still set a reminder, but only while that session stays open.
|
||||
|
||||
## Operate your account by conversation
|
||||
|
||||
Anything you can do on the site, you can ask Devii to do - and it confirms the result.
|
||||
@@ -95,6 +113,12 @@ short tips, scrolling to the right place, and moving you between pages.
|
||||
|
||||
> Take me to my notifications and point out how to mark them all read.
|
||||
|
||||
## Chat from Telegram
|
||||
|
||||
Prefer your phone? Connect your Telegram account and talk to the same Devii from the DevPlace
|
||||
bot, with markdown replies, a typing indicator, and image understanding. See
|
||||
[Devii on Telegram](/docs/telegram.html) to pair in under a minute.
|
||||
|
||||
## Your built-in docs helper
|
||||
|
||||
Not sure how an endpoint works? Ask in the terminal on any documentation page.
|
||||
|
||||
@@ -40,6 +40,7 @@ box saves immediately - there is no separate save button.
|
||||
| Badges | you earn a badge |
|
||||
| Level-ups | you reach a new level |
|
||||
| Issue tracker | there is an update on an issue report you filed |
|
||||
| Reminders | a reminder or scheduled task you asked Devii to run fires |
|
||||
|
||||
## Defaults
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Database API service
|
||||
|
||||
The database API is a single, safe surface for **reading** every table in the platform. It exposes generic per-table reads, a validated read-only `query()`, a natural-language-to-SQL designer backed by the platform AI gateway, and asynchronous query execution streamed over a websocket. It is mounted at `/dbapi` and is reachable only by administrators or by internal services. The database API is **strictly read-only**: it can never insert, update, replace, delete, or restore data in any way.
|
||||
The database API is a single, safe surface for **reading** every table in the platform. It exposes generic per-table reads, a validated read-only `query()`, a natural-language-to-SQL designer backed by the platform AI gateway, and asynchronous query execution streamed over a websocket. It is mounted at `/dbapi` and is reachable only by the **primary administrator** (the oldest Admin account). The database API is **strictly read-only**: it can never insert, update, replace, delete, or restore data in any way.
|
||||
|
||||
> Audience: administrators, maintainers, and trusted internal services.
|
||||
> Audience: the primary administrator only.
|
||||
|
||||
It reuses the existing data layer (`dataset` with the production pragmas), the [async job framework](/docs/architecture-jobs.html), the [AI gateway](/docs/services-gateway.html), and the Devii action catalog. It does not introduce a second database or a new ORM.
|
||||
|
||||
@@ -11,10 +11,9 @@ It reuses the existing data layer (`dataset` with the production pragmas), the [
|
||||
|
||||
There is exactly one authorization boundary, enforced on every route:
|
||||
|
||||
- An **administrator** authenticated by session cookie or by an admin API key.
|
||||
- An **internal service** presenting the gateway internal key as `Authorization: Bearer <key>` or `X-API-KEY: <key>`.
|
||||
- The **primary administrator**, and no one else, authenticated by session cookie or by their API key. The primary administrator is the earliest-created Admin account (resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`, the same identity that gates backup downloads). Every other administrator is treated like a member here.
|
||||
|
||||
Anyone else (members, guests) receives `403 Forbidden`, and the denial is written to the audit log as `database.access.denied`. There is no public or member-facing access.
|
||||
There is no internal-key or service-to-service access: the gateway internal key is **not** accepted. Anyone else (members, guests, non-primary administrators, and internal callers) receives `403 Forbidden`, and the denial is written to the audit log as `database.access.denied`. There is no public, member-facing, junior-admin, or service access.
|
||||
|
||||
## Tables and the deny list
|
||||
|
||||
@@ -83,7 +82,7 @@ POST /dbapi/nl
|
||||
}
|
||||
```
|
||||
|
||||
The model is configurable (`dbapi_nl_model`, blank uses the internal `molodetz` model), as is an optional operator preamble (`dbapi_nl_system_preamble`). The call is attributed to the calling administrator's API key so its cost rolls up under that user.
|
||||
The model is configurable (`dbapi_nl_model`, blank uses the internal `molodetz` model), as is an optional operator preamble (`dbapi_nl_system_preamble`). The call is attributed to the calling primary administrator's API key so its cost rolls up under that user.
|
||||
|
||||
## Asynchronous queries
|
||||
|
||||
@@ -98,11 +97,11 @@ The job writes its result to the runtime data directory (`config.DBAPI_DIR/{uid}
|
||||
|
||||
## Devii
|
||||
|
||||
An administrator's Devii assistant exposes the same read-only capability conversationally through admin-only tools:
|
||||
The **primary administrator's** Devii assistant exposes the same read-only capability conversationally through primary-administrator-only tools:
|
||||
|
||||
- `db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`, `db_query` (SELECT only, and it surfaces any `suspicious` warnings), and `db_design_query` (natural language to SQL).
|
||||
|
||||
There are no write tools. Devii cannot insert, update, or delete data through the database API.
|
||||
These tools are added to Devii's tool list **only for the primary administrator**. Members, guests, and non-primary administrators never see them in their session, so their Devii is not even aware the database API exists. There are no write tools. Devii cannot insert, update, or delete data through the database API.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -117,9 +116,9 @@ On `/admin/services` the **Database API** service (`dbquery`) exposes:
|
||||
## Security summary
|
||||
|
||||
- The API is strictly read-only: there are no insert, update, delete, or restore routes or tools, so it can never change data.
|
||||
- One authorization boundary: administrator or internal key, enforced on every route, audited on denial.
|
||||
- One authorization boundary: the primary administrator (the oldest Admin) only, enforced on every route, audited on denial. Non-primary administrators, members, guests, and internal callers are all refused.
|
||||
- Table allow/deny guard on every table-scoped path and on every table referenced by a query.
|
||||
- Raw SQL is always read-only, on a dedicated `query_only` connection, so even a validator miss cannot mutate.
|
||||
- Reads exclude soft-deleted rows by default (opt in with `?include_deleted=true`).
|
||||
- All Devii database tools are admin-only and read-only.
|
||||
- All Devii database tools are primary-administrator-only and read-only, and are withheld from every other session's tool list.
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user