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
+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()