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

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