forked from retoor/devplacepy
docs: document server-side rendering pipeline, response timing middleware, and Telegram pairing API
- Add comprehensive documentation for backend content rendering in AGENTS.md, detailing the new `render_content` and `render_title` Jinja globals built on mistune with media processing, emoji shortcodes, and XSS protection
- Document the `X-Response-Time` header and bottom-left render time indicator in README.md
- Update bot token pricing documentation to clarify fallback vs gateway cost headers
- Add `email_accounts` to soft-delete tables and `idx_users_role` composite index in database schema
- Implement `telegram_pairings` and `telegram_links` table creation with column migration and indexes
- Add `/profile/{username}/telegram` endpoint to docs API with request/unpair actions
- Register `TelegramService` in main.py lifespan and add `response_timing` middleware emitting `X-Response-Time` header
- Introduce `TelegramPairForm` model and `guard_public_host_sync` synchronous host validation function
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
@@ -10,8 +9,9 @@ from devplacepy.services.ai_context import build_context
|
||||
from devplacepy.services.background import background
|
||||
from devplacepy.services.correction import (
|
||||
CORRECTABLE_FIELDS,
|
||||
PENDING_SCOPE_KEY,
|
||||
gateway_complete,
|
||||
new_usage_totals,
|
||||
schedule_pending,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,35 +60,13 @@ def schedule_modification(
|
||||
return
|
||||
prompt = (user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT).strip()
|
||||
user_uid = user.get("uid") or ""
|
||||
if user.get("ai_modifier_sync") and _run_inline_awaited(
|
||||
api_key, prompt, table, uid, user_uid, request
|
||||
if user.get("ai_modifier_sync") and schedule_pending(
|
||||
_run_modification, request, api_key, prompt, table, uid, user_uid
|
||||
):
|
||||
return
|
||||
background.submit(_run_modification, api_key, prompt, table, uid, user_uid)
|
||||
|
||||
|
||||
def _run_inline_awaited(
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
table: str,
|
||||
uid: str,
|
||||
user_uid: str,
|
||||
request: object,
|
||||
) -> bool:
|
||||
scope = getattr(request, "scope", None)
|
||||
if scope is None:
|
||||
return False
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return False
|
||||
future = loop.run_in_executor(
|
||||
None, _run_modification, api_key, prompt, table, uid, user_uid
|
||||
)
|
||||
scope.setdefault(PENDING_SCOPE_KEY, []).append(future)
|
||||
return True
|
||||
|
||||
|
||||
def _run_modification(
|
||||
api_key: str, prompt: str, table: str, uid: str, user_uid: str
|
||||
) -> None:
|
||||
@@ -99,15 +77,7 @@ def _run_modification(
|
||||
if not row:
|
||||
return
|
||||
updates: dict = {}
|
||||
totals = {
|
||||
"calls": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"upstream_latency_ms": 0.0,
|
||||
"total_latency_ms": 0.0,
|
||||
}
|
||||
totals = new_usage_totals()
|
||||
context = None
|
||||
for field in fields:
|
||||
original = row.get(field) or ""
|
||||
|
||||
@@ -34,7 +34,9 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
|
||||
"ai": "ai",
|
||||
"database": "database",
|
||||
"pubsub": "pubsub",
|
||||
"telegram": "telegram",
|
||||
"devii": "devii",
|
||||
"email": "email",
|
||||
"cli": "cli",
|
||||
"reward": "reward",
|
||||
"security": "security",
|
||||
|
||||
@@ -336,16 +336,22 @@ class DevPlaceBot:
|
||||
return ""
|
||||
return (key or "").strip()
|
||||
|
||||
async def _adopt_account_api_key(self) -> None:
|
||||
async def _adopt_account_api_key(self) -> bool:
|
||||
if self.state.account_api_key:
|
||||
return
|
||||
key = await self._fetch_account_api_key()
|
||||
if not key:
|
||||
return
|
||||
self.state.account_api_key = key
|
||||
self.llm.api_key = key
|
||||
self._save()
|
||||
self._log("Adopted account API key for gateway calls")
|
||||
self.llm.api_key = self.state.account_api_key
|
||||
return True
|
||||
for attempt in range(5):
|
||||
key = await self._fetch_account_api_key()
|
||||
if key:
|
||||
self.state.account_api_key = key
|
||||
self.llm.api_key = key
|
||||
self._save()
|
||||
self._log("Adopted account API key for gateway calls")
|
||||
return True
|
||||
if attempt < 4:
|
||||
await asyncio.sleep(2)
|
||||
self._log("Account API key unavailable after retries")
|
||||
return False
|
||||
|
||||
async def _read_like_human(self, min_s: float = 3.0, max_s: float = 90.0) -> None:
|
||||
if not self.state.reading_speed_wpm:
|
||||
@@ -2833,7 +2839,11 @@ class DevPlaceBot:
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
await self._adopt_account_api_key()
|
||||
if not await self._adopt_account_api_key():
|
||||
self._log("No own API key yet; ending session to avoid shared-key billing")
|
||||
await b.close()
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
self._bind_monitor()
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ BASE_URL_DEFAULT = "https://pravda.education"
|
||||
API_URL_DEFAULT = INTERNAL_GATEWAY_URL
|
||||
NEWS_API_DEFAULT = "https://news.app.molodetz.nl/api"
|
||||
MODEL_DEFAULT = INTERNAL_MODEL
|
||||
INPUT_COST_PER_1M_DEFAULT = 0.27
|
||||
OUTPUT_COST_PER_1M_DEFAULT = 1.10
|
||||
INPUT_COST_PER_1M_DEFAULT = 0.14
|
||||
OUTPUT_COST_PER_1M_DEFAULT = 0.28
|
||||
|
||||
COST_WINDOW_SECONDS = 600
|
||||
COST_WARMUP_SECONDS = 120
|
||||
|
||||
@@ -9,15 +9,16 @@ from devplacepy.services.bot.config import HANDLE_SUFFIX_WORDS
|
||||
MIN_HANDLE_LEN = 3
|
||||
MAX_HANDLE_LEN = 20
|
||||
MIN_SEED_TOKEN_LEN = 3
|
||||
LEET_PROBABILITY = 0.35
|
||||
LEET_CHAR_PROBABILITY = 0.5
|
||||
NUMBER_SUFFIX_PROBABILITY = 0.30
|
||||
SEPARATOR_PROBABILITY = 0.30
|
||||
CAPITALIZE_PROBABILITY = 0.22
|
||||
ALLCAPS_PROBABILITY = 0.08
|
||||
LEET_CHAR_PROBABILITY = 0.35
|
||||
NUMBER_SUFFIX_PROBABILITY = 0.26
|
||||
DECORATION_STYLES = ["plain", "leet", "vowel"]
|
||||
DECORATION_WEIGHTS = [64, 22, 14]
|
||||
CAPITALIZE_PROBABILITY = 0.20
|
||||
ALLCAPS_PROBABILITY = 0.06
|
||||
ALLCAPS_MAX_LEN = 8
|
||||
PAD_NUMBER_FLOOR = 10
|
||||
PAD_NUMBER_CEIL = 9999
|
||||
VOWELS = "aeiou"
|
||||
|
||||
LEET_MAP = {
|
||||
"a": "4",
|
||||
@@ -39,33 +40,60 @@ TECH_NOUNS = [
|
||||
"mutex", "syntax", "binary", "octet", "raster", "shader", "codec",
|
||||
"vertex", "matrix", "scalar", "payload", "opcode", "runtime", "compiler",
|
||||
"parser", "lexer", "sandbox", "firmware", "bytecode", "checksum",
|
||||
"entropy", "signal", "overflow", "nibbler", "cursor", "patch",
|
||||
"entropy", "signal", "overflow", "nibbler", "cursor", "patch", "semaphore",
|
||||
"spinlock", "deadlock", "webhook", "endpoint", "gateway", "registry",
|
||||
"pipeline", "tensor", "gradient", "cluster", "shard", "replica", "schema",
|
||||
"commit", "branch", "rebase", "stash", "blob", "symlink", "inode", "mmap",
|
||||
"syscall", "interrupt", "watchdog", "heartbeat", "beacon", "subnet",
|
||||
"datagram", "loopback", "nullref", "dangle", "leak", "trace", "kilobyte",
|
||||
"qubit", "monad", "closure", "thunk", "fiber", "coro", "atom",
|
||||
]
|
||||
|
||||
HANDLE_ADJECTIVES = [
|
||||
"dark", "mad", "lazy", "quantum", "cyber", "neon", "ghost", "hyper",
|
||||
"ultra", "neo", "retro", "raw", "zero", "async", "blind", "silent",
|
||||
"atomic", "fatal", "rogue", "idle", "stale", "dirty", "lucid", "prime",
|
||||
"hex", "cold", "lone", "wired", "broken", "feral",
|
||||
"hex", "cold", "lone", "wired", "broken", "feral", "stray", "numb",
|
||||
"vivid", "molten", "frozen", "hollow", "jagged", "static", "volatile",
|
||||
"nomadic", "unsigned", "verbose", "terse", "nocturnal", "caffeinated",
|
||||
"deprecated", "headless", "stateless", "immutable", "recursive",
|
||||
]
|
||||
|
||||
HANDLE_CREATURES = [
|
||||
"fox", "wolf", "raven", "owl", "octopus", "gecko", "mantis", "viper",
|
||||
"lynx", "moth", "hydra", "kraken", "phoenix", "dragon", "falcon",
|
||||
"panther", "cobra", "badger", "otter", "sloth", "hornet", "beetle",
|
||||
"lemur", "narwhal", "basilisk",
|
||||
"lemur", "narwhal", "basilisk", "magpie", "heron", "ferret", "weasel",
|
||||
"jackal", "marten", "axolotl", "tardigrade", "wyvern", "griffin",
|
||||
"salamander", "newt", "stoat", "crow", "shrike", "ibis",
|
||||
]
|
||||
|
||||
HANDLE_PREFIXES = ["the", "mr", "dr", "lord", "captain", "uncle", "sir", "prof", "agent"]
|
||||
HANDLE_PREFIXES = [
|
||||
"the", "mr", "dr", "lord", "captain", "uncle", "sir", "prof", "agent",
|
||||
"old", "lil", "big", "saint", "baron", "comrade", "major",
|
||||
]
|
||||
|
||||
NUMBER_SUFFIXES = ["42", "1337", "404", "64", "99", "007", "23", "88", "256", "512", "101", "777"]
|
||||
ROLE_WORDS = [
|
||||
"dev", "coder", "hacker", "ninja", "guru", "wizard", "smith", "monk",
|
||||
"nomad", "ghost", "pilot", "surgeon", "gremlin", "goblin", "wrangler",
|
||||
"jockey", "slinger", "herder", "whisperer", "monkey", "sage", "druid",
|
||||
"ronin", "samurai", "pirate", "tinkerer", "alchemist", "janitor",
|
||||
"plumber", "gardener", "scribe", "warden", "ranger", "miner", "forge",
|
||||
]
|
||||
|
||||
SEPARATORS = ["_", "-"]
|
||||
NUMBER_SUFFIXES = [
|
||||
"42", "1337", "404", "64", "99", "007", "23", "88", "256", "512", "101",
|
||||
"777", "13", "21", "69", "420", "127", "255", "80", "443", "3000", "8080",
|
||||
"2k", "9000", "65535", "16", "32", "128",
|
||||
]
|
||||
|
||||
VERSION_TAGS = [
|
||||
"v2", "v3", "x64", "x86", "js", "py", "sh", "io", "fn", "db", "ai", "os",
|
||||
"hq", "exe", "dev", "rs", "go", "cpp", "ml",
|
||||
]
|
||||
|
||||
def _join(rng: random.Random, parts: list[str]) -> str:
|
||||
separator = rng.choice(SEPARATORS) if rng.random() < SEPARATOR_PROBABILITY else ""
|
||||
return separator.join(parts)
|
||||
JOIN_STYLES = ["none", "under", "hyphen", "camel"]
|
||||
JOIN_WEIGHTS = [46, 19, 12, 23]
|
||||
|
||||
|
||||
def _seed_tokens(interests: list[str]) -> list[str]:
|
||||
@@ -77,26 +105,81 @@ def _seed_tokens(interests: list[str]) -> list[str]:
|
||||
return tokens
|
||||
|
||||
|
||||
def _build_core(rng: random.Random, interests: list[str]) -> str:
|
||||
seeds = _seed_tokens(interests)
|
||||
builders = [
|
||||
lambda: rng.choice(TECH_NOUNS),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(TECH_NOUNS)]),
|
||||
lambda: _join(rng, [rng.choice(TECH_NOUNS), rng.choice(HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_PREFIXES), rng.choice(TECH_NOUNS + HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(TECH_NOUNS), rng.choice(HANDLE_SUFFIX_WORDS)]),
|
||||
def _seed_word(rng: random.Random, seeds: list[str]) -> str:
|
||||
return rng.choice(seeds) if seeds else rng.choice(TECH_NOUNS)
|
||||
|
||||
|
||||
def _tokens(rng: random.Random, seeds: list[str]) -> list[str]:
|
||||
noun = lambda: rng.choice(TECH_NOUNS)
|
||||
adjective = lambda: rng.choice(HANDLE_ADJECTIVES)
|
||||
creature = lambda: rng.choice(HANDLE_CREATURES)
|
||||
role = lambda: rng.choice(ROLE_WORDS)
|
||||
prefix = lambda: rng.choice(HANDLE_PREFIXES)
|
||||
suffix = lambda: rng.choice(HANDLE_SUFFIX_WORDS)
|
||||
seed = lambda: _seed_word(rng, seeds)
|
||||
plans = [
|
||||
(7, lambda: [noun()]),
|
||||
(3, lambda: [role()]),
|
||||
(8, lambda: [adjective(), noun()]),
|
||||
(6, lambda: [noun(), creature()]),
|
||||
(5, lambda: [adjective(), creature()]),
|
||||
(5, lambda: [noun(), role()]),
|
||||
(4, lambda: [adjective(), role()]),
|
||||
(4, lambda: [creature(), role()]),
|
||||
(4, lambda: [prefix(), noun()]),
|
||||
(3, lambda: [prefix(), creature()]),
|
||||
(4, lambda: [role(), noun()]),
|
||||
(3, lambda: [noun(), suffix()]),
|
||||
(3, lambda: [adjective(), suffix()]),
|
||||
(3, lambda: [noun(), noun()]),
|
||||
(2, lambda: [adjective(), noun(), creature()]),
|
||||
(2, lambda: [prefix(), adjective(), noun()]),
|
||||
(6, lambda: [seed(), role()]),
|
||||
(5, lambda: [adjective(), seed()]),
|
||||
(4, lambda: [seed(), noun()]),
|
||||
(3, lambda: [seed()]),
|
||||
]
|
||||
weights = [3, 4, 3, 2, 2, 2]
|
||||
if seeds:
|
||||
builders.append(
|
||||
lambda: _join(rng, [rng.choice(seeds), rng.choice(HANDLE_SUFFIX_WORDS + TECH_NOUNS)])
|
||||
)
|
||||
builders.append(lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(seeds)]))
|
||||
weights += [5, 3]
|
||||
weights = [weight for weight, _ in plans]
|
||||
builders = [builder for _, builder in plans]
|
||||
return rng.choices(builders, weights=weights)[0]()
|
||||
|
||||
|
||||
def _dedupe(rng: random.Random, tokens: list[str]) -> list[str]:
|
||||
result: list[str] = []
|
||||
for token in tokens:
|
||||
if result and result[-1] == token:
|
||||
token = rng.choice(TECH_NOUNS)
|
||||
result.append(token)
|
||||
return result
|
||||
|
||||
|
||||
def _fuse(rng: random.Random, tokens: list[str]) -> str:
|
||||
if len(tokens) == 1:
|
||||
return tokens[0]
|
||||
style = rng.choices(JOIN_STYLES, weights=JOIN_WEIGHTS)[0]
|
||||
if style == "camel":
|
||||
head, *rest = tokens
|
||||
return head + "".join(part[:1].upper() + part[1:] for part in rest)
|
||||
separator = {"none": "", "under": "_", "hyphen": "-"}[style]
|
||||
return separator.join(tokens)
|
||||
|
||||
|
||||
def _drop_vowels(rng: random.Random, text: str) -> str:
|
||||
kept = [text[0]]
|
||||
for char in text[1:]:
|
||||
if char in VOWELS and rng.random() < 0.55:
|
||||
continue
|
||||
kept.append(char)
|
||||
result = "".join(kept)
|
||||
return result if len(result) >= MIN_HANDLE_LEN else text
|
||||
|
||||
|
||||
def _maybe_tag(rng: random.Random, core: str) -> str:
|
||||
if rng.random() >= NUMBER_SUFFIX_PROBABILITY:
|
||||
return core
|
||||
return f"{core}{rng.choice(NUMBER_SUFFIXES + VERSION_TAGS)}"
|
||||
|
||||
|
||||
def apply_leet(text: str, rng: random.Random) -> str:
|
||||
chars: list[str] = []
|
||||
for char in text:
|
||||
@@ -108,7 +191,7 @@ def apply_leet(text: str, rng: random.Random) -> str:
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def _apply_case(text: str, rng: random.Random) -> str:
|
||||
def _apply_case(rng: random.Random, text: str) -> str:
|
||||
roll = rng.random()
|
||||
if roll < ALLCAPS_PROBABILITY and len(text) <= ALLCAPS_MAX_LEN:
|
||||
return text.upper()
|
||||
@@ -125,13 +208,17 @@ def sanitize_handle(raw: str) -> str:
|
||||
|
||||
def make_handle(interests: Optional[list[str]] = None, rng: Optional[random.Random] = None) -> str:
|
||||
generator = rng or random
|
||||
core = _build_core(generator, interests or [])
|
||||
if generator.random() < LEET_PROBABILITY:
|
||||
seeds = _seed_tokens(interests or [])
|
||||
core = _fuse(generator, _dedupe(generator, _tokens(generator, seeds)))
|
||||
decoration = generator.choices(DECORATION_STYLES, weights=DECORATION_WEIGHTS)[0]
|
||||
if decoration == "vowel" and core.isalpha() and core.islower() and len(core) > 6:
|
||||
core = _drop_vowels(generator, core)
|
||||
elif decoration == "leet":
|
||||
core = apply_leet(core, generator)
|
||||
if generator.random() < NUMBER_SUFFIX_PROBABILITY:
|
||||
core = f"{core}{generator.choice(NUMBER_SUFFIXES)}"
|
||||
if decoration != "leet":
|
||||
core = _maybe_tag(generator, core)
|
||||
handle = sanitize_handle(core)
|
||||
if len(handle) < MIN_HANDLE_LEN:
|
||||
handle = f"{handle}{generator.randint(PAD_NUMBER_FLOOR, PAD_NUMBER_CEIL)}"
|
||||
handle = _apply_case(handle, generator)
|
||||
handle = _apply_case(generator, handle)
|
||||
return handle or f"dev{generator.randint(PAD_NUMBER_FLOOR, PAD_NUMBER_CEIL)}"
|
||||
|
||||
@@ -19,6 +19,7 @@ from devplacepy.services.bot.config import (
|
||||
TRIVIAL_GIST_TERMS,
|
||||
)
|
||||
from devplacepy.services.bot.handles import MAX_HANDLE_LEN, sanitize_handle
|
||||
from devplacepy.services.openai_gateway.usage import parse_usage_headers
|
||||
|
||||
HANDLE_CANDIDATE_TARGET = 8
|
||||
|
||||
@@ -74,11 +75,8 @@ class LLMClient:
|
||||
raw = resp.content
|
||||
logger.info("LLM <<< %s", raw[:2000].decode(errors="replace"))
|
||||
result = resp.json()
|
||||
usage = result.get("usage", {})
|
||||
in_tokens = usage.get("prompt_tokens", 0)
|
||||
out_tokens = usage.get("completion_tokens", 0)
|
||||
cost = (in_tokens * self.input_cost_per_1m / 1_000_000) + (
|
||||
out_tokens * self.output_cost_per_1m / 1_000_000
|
||||
in_tokens, out_tokens, cost = self._account_usage(
|
||||
resp.headers, result.get("usage", {})
|
||||
)
|
||||
self.total_cost += cost
|
||||
self.total_calls += 1
|
||||
@@ -96,6 +94,21 @@ class LLMClient:
|
||||
time.sleep(2**attempt)
|
||||
return ""
|
||||
|
||||
def _account_usage(self, response_headers, body_usage: dict) -> tuple[int, int, float]:
|
||||
parsed = parse_usage_headers(response_headers)
|
||||
if parsed is not None:
|
||||
in_tokens = parsed["prompt_tokens"]
|
||||
out_tokens = parsed["completion_tokens"]
|
||||
if not out_tokens and parsed["total_tokens"] > in_tokens:
|
||||
out_tokens = parsed["total_tokens"] - in_tokens
|
||||
return in_tokens, out_tokens, parsed["cost_usd"]
|
||||
in_tokens = body_usage.get("prompt_tokens", 0)
|
||||
out_tokens = body_usage.get("completion_tokens", 0)
|
||||
cost = (in_tokens * self.input_cost_per_1m / 1_000_000) + (
|
||||
out_tokens * self.output_cost_per_1m / 1_000_000
|
||||
)
|
||||
return in_tokens, out_tokens, cost
|
||||
|
||||
def _call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
|
||||
return self.clean(self._raw_call(system, prompt, temperature))
|
||||
|
||||
@@ -496,12 +509,20 @@ class LLMClient:
|
||||
"You invent online handles for a developer signing up to a programmer "
|
||||
"community in the style of devRant or Hacker News. The handles read like a "
|
||||
"real nerd picked them, never like a person's full name. Lean on programming "
|
||||
"and hacker culture: tech nouns (null, kernel, segfault, daemon), occasional "
|
||||
"leetspeak (c0d3r, h4x), an adjective plus noun, a creature, or a short word "
|
||||
f"with a number. Lowercase mostly, {MAX_HANDLE_LEN} characters or fewer, only "
|
||||
"letters, digits, underscores and hyphens, no spaces and no dots. Return ONLY "
|
||||
f'a JSON object {{"handles": ["...", "..."]}} with {HANDLE_CANDIDATE_TARGET} '
|
||||
"distinct handles, no prose, no markdown fences. No em dashes."
|
||||
"and hacker culture, but make every handle in the list a DIFFERENT shape so "
|
||||
"they never feel formulaic. Mix these forms across the list: a single fused "
|
||||
"word (segfault, mutexlord, kernelpanic); two words joined with no separator "
|
||||
"(darkbyte, neonferret); camelCase (nullPointer, byteFox); an underscore or a "
|
||||
"hyphen but NOT on most of them (lazy_daemon, cold-stack); leetspeak "
|
||||
"(n0_scalar, c0d3r, h4xwolf); a word plus a number, port, or version tag "
|
||||
"(void404, daemon1337, byte_v2, heap8080); dropped vowels (krnlpnc, bffr, "
|
||||
"mtxguru); and an unexpected creature, role, or prefix (dr_segfault, "
|
||||
"raven_smith, axolotl_dev). Vary the length from 4 to 18. Lowercase mostly "
|
||||
"with the odd capital. Do NOT make them all the 'word_word' underscore "
|
||||
f"pattern. {MAX_HANDLE_LEN} characters or fewer, only letters, digits, "
|
||||
"underscores and hyphens, no spaces and no dots. Return ONLY a JSON object "
|
||||
f'{{"handles": ["...", "..."]}} with {HANDLE_CANDIDATE_TARGET} distinct '
|
||||
"handles, each a distinct shape, no prose, no markdown fences. No em dashes."
|
||||
)
|
||||
prompt = (
|
||||
f"Archetype: {archetype}\n"
|
||||
|
||||
@@ -98,7 +98,7 @@ class BotsService(BaseService):
|
||||
type="float",
|
||||
default=config.INPUT_COST_PER_1M_DEFAULT,
|
||||
minimum=0,
|
||||
help="Used to compute live spend per bot and across the fleet.",
|
||||
help="Fallback pricing, used only when the LLM endpoint returns no gateway cost headers. The internal gateway reports authoritative cache-aware cost per call.",
|
||||
group="LLM",
|
||||
),
|
||||
ConfigField(
|
||||
@@ -107,7 +107,7 @@ class BotsService(BaseService):
|
||||
type="float",
|
||||
default=config.OUTPUT_COST_PER_1M_DEFAULT,
|
||||
minimum=0,
|
||||
help="Used to compute live spend per bot and across the fleet.",
|
||||
help="Fallback pricing, used only when the LLM endpoint returns no gateway cost headers. The internal gateway reports authoritative cache-aware cost per call.",
|
||||
group="LLM",
|
||||
),
|
||||
ConfigField(
|
||||
|
||||
@@ -13,6 +13,7 @@ from devplacepy.config import (
|
||||
)
|
||||
from devplacepy.database import add_correction_usage, get_table
|
||||
from devplacepy.services.background import background
|
||||
from devplacepy.services.openai_gateway.usage import parse_usage_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,26 +32,25 @@ PENDING_SCOPE_KEY = "devplace_pending_corrections"
|
||||
|
||||
|
||||
def _usage_from_headers(response_headers) -> dict:
|
||||
def _int(name: str) -> int:
|
||||
try:
|
||||
return int(response_headers.get(name) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def _float(name: str) -> float:
|
||||
try:
|
||||
return float(response_headers.get(name) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
parsed = parse_usage_headers(response_headers)
|
||||
if parsed is None:
|
||||
return {
|
||||
"calls": 1,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"upstream_latency_ms": 0.0,
|
||||
"total_latency_ms": 0.0,
|
||||
}
|
||||
return {
|
||||
"calls": 1,
|
||||
"prompt_tokens": _int("X-Gateway-Prompt-Tokens"),
|
||||
"completion_tokens": _int("X-Gateway-Completion-Tokens"),
|
||||
"total_tokens": _int("X-Gateway-Total-Tokens"),
|
||||
"cost_usd": _float("X-Gateway-Cost-USD"),
|
||||
"upstream_latency_ms": _float("X-Gateway-Upstream-Latency-Ms"),
|
||||
"total_latency_ms": _float("X-Gateway-Total-Latency-Ms"),
|
||||
"calls": parsed["calls"],
|
||||
"prompt_tokens": parsed["prompt_tokens"],
|
||||
"completion_tokens": parsed["completion_tokens"],
|
||||
"total_tokens": parsed["total_tokens"],
|
||||
"cost_usd": parsed["cost_usd"],
|
||||
"upstream_latency_ms": parsed["upstream_latency_ms"],
|
||||
"total_latency_ms": parsed["total_latency_ms"],
|
||||
}
|
||||
|
||||
|
||||
@@ -123,21 +123,14 @@ def schedule_correction(
|
||||
return
|
||||
prompt = (user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT).strip()
|
||||
user_uid = user.get("uid") or ""
|
||||
if user.get("ai_correction_sync") and _run_inline_awaited(
|
||||
api_key, prompt, table, uid, user_uid, request
|
||||
if user.get("ai_correction_sync") and schedule_pending(
|
||||
_run_correction, request, api_key, prompt, table, uid, user_uid
|
||||
):
|
||||
return
|
||||
background.submit(_run_correction, api_key, prompt, table, uid, user_uid)
|
||||
|
||||
|
||||
def _run_inline_awaited(
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
table: str,
|
||||
uid: str,
|
||||
user_uid: str,
|
||||
request: object,
|
||||
) -> bool:
|
||||
def schedule_pending(fn, request: object, *args) -> bool:
|
||||
scope = getattr(request, "scope", None)
|
||||
if scope is None:
|
||||
return False
|
||||
@@ -145,13 +138,23 @@ def _run_inline_awaited(
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return False
|
||||
future = loop.run_in_executor(
|
||||
None, _run_correction, api_key, prompt, table, uid, user_uid
|
||||
)
|
||||
future = loop.run_in_executor(None, fn, *args)
|
||||
scope.setdefault(PENDING_SCOPE_KEY, []).append(future)
|
||||
return True
|
||||
|
||||
|
||||
def new_usage_totals() -> dict:
|
||||
return {
|
||||
"calls": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"upstream_latency_ms": 0.0,
|
||||
"total_latency_ms": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _run_correction(
|
||||
api_key: str, prompt: str, table: str, uid: str, user_uid: str
|
||||
) -> None:
|
||||
@@ -162,15 +165,7 @@ def _run_correction(
|
||||
if not row:
|
||||
return
|
||||
updates: dict = {}
|
||||
totals = {
|
||||
"calls": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"upstream_latency_ms": 0.0,
|
||||
"total_latency_ms": 0.0,
|
||||
}
|
||||
totals = new_usage_totals()
|
||||
for field in fields:
|
||||
original = row.get(field) or ""
|
||||
if not original.strip():
|
||||
|
||||
@@ -9,9 +9,8 @@ from devplacepy.database import (
|
||||
SOFT_DELETE_TABLES,
|
||||
db,
|
||||
get_setting,
|
||||
internal_gateway_key,
|
||||
)
|
||||
from devplacepy.utils import _user_from_api_key, get_current_user, is_admin
|
||||
from devplacepy.utils import _user_from_api_key, get_current_user, is_primary_admin
|
||||
|
||||
TABLE_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
DEFAULT_DENY = {"sessions", "password_resets", "cache_state"}
|
||||
@@ -32,10 +31,6 @@ class Caller:
|
||||
username: str
|
||||
api_key: str
|
||||
|
||||
@property
|
||||
def is_internal(self) -> bool:
|
||||
return self.kind == "internal"
|
||||
|
||||
|
||||
def deny_tables() -> set[str]:
|
||||
raw = get_setting("dbapi_deny_tables", "")
|
||||
@@ -55,7 +50,7 @@ def _bearer_key(request) -> str:
|
||||
|
||||
def caller_for(request) -> Caller | None:
|
||||
user = get_current_user(request)
|
||||
if user and is_admin(user):
|
||||
if user and is_primary_admin(user):
|
||||
return Caller(
|
||||
kind="admin",
|
||||
uid=user["uid"],
|
||||
@@ -63,11 +58,9 @@ def caller_for(request) -> Caller | None:
|
||||
api_key=user.get("api_key", "") or "",
|
||||
)
|
||||
key = _bearer_key(request)
|
||||
if key and key == internal_gateway_key():
|
||||
return Caller(kind="internal", uid="internal", username="internal", api_key=key)
|
||||
if key:
|
||||
keyed = _user_from_api_key(key)
|
||||
if keyed and is_admin(keyed):
|
||||
if keyed and is_primary_admin(keyed):
|
||||
return Caller(
|
||||
kind="admin",
|
||||
uid=keyed["uid"],
|
||||
@@ -80,7 +73,7 @@ def caller_for(request) -> Caller | None:
|
||||
def require_caller(request) -> Caller:
|
||||
caller = caller_for(request)
|
||||
if caller is None:
|
||||
raise DbApiDenied("Administrator or internal-key access required.")
|
||||
raise DbApiDenied("Primary-administrator access required.")
|
||||
return caller
|
||||
|
||||
|
||||
|
||||
@@ -1531,7 +1531,7 @@ ACTIONS: tuple[Action, ...] = (
|
||||
name="db_list_tables",
|
||||
method="GET",
|
||||
path="/dbapi/tables",
|
||||
summary="List database tables exposed by the database API (admin only)",
|
||||
summary="List database tables exposed by the database API (primary administrator only)",
|
||||
description=(
|
||||
"Returns every table reachable through the database API with its row count and "
|
||||
"whether it uses soft deletes. Use this to discover what data exists before "
|
||||
@@ -1539,21 +1539,23 @@ ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
params=(),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_table_schema",
|
||||
method="GET",
|
||||
path="/dbapi/{table}/schema",
|
||||
summary="Show a table's columns and types (admin only)",
|
||||
summary="Show a table's columns and types (primary administrator only)",
|
||||
description="Returns the column names, types, row count, and soft-delete flag for one table.",
|
||||
params=(path("table", "Table name (from db_list_tables)."),),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_list_rows",
|
||||
method="GET",
|
||||
path="/dbapi/{table}",
|
||||
summary="List rows of a table with keyset pagination (admin only)",
|
||||
summary="List rows of a table with keyset pagination (primary administrator only)",
|
||||
description=(
|
||||
"Browses rows newest-first. Soft-deleted rows are excluded unless include_deleted is "
|
||||
"true. For filtered or joined questions prefer db_query or db_design_query."
|
||||
@@ -1572,12 +1574,13 @@ ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_get_row",
|
||||
method="GET",
|
||||
path="/dbapi/{table}/{key}/{value}",
|
||||
summary="Fetch one row by a key column (admin only)",
|
||||
summary="Fetch one row by a key column (primary administrator only)",
|
||||
description="Returns a single row where key column equals value (key is usually 'uid').",
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
@@ -1585,12 +1588,13 @@ ACTIONS: tuple[Action, ...] = (
|
||||
path("value", "Value of the key column."),
|
||||
),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_query",
|
||||
method="POST",
|
||||
path="/dbapi/query",
|
||||
summary="Run a read-only SQL SELECT and return rows (admin only)",
|
||||
summary="Run a read-only SQL SELECT and return rows (primary administrator only)",
|
||||
description=(
|
||||
"Executes a SINGLE validated SELECT statement read-only and returns the rows. Only "
|
||||
"SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected. The database API is "
|
||||
@@ -1603,13 +1607,14 @@ ACTIONS: tuple[Action, ...] = (
|
||||
body("dialect", "Optional source SQL dialect (default sqlite)."),
|
||||
),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="db_design_query",
|
||||
method="POST",
|
||||
path="/dbapi/nl",
|
||||
summary="Design a SQL SELECT from a natural-language question (admin only)",
|
||||
summary="Design a SQL SELECT from a natural-language question (primary administrator only)",
|
||||
description=(
|
||||
"Turns a plain-language question about one table into a validated read-only SELECT. "
|
||||
"It auto-adds 'deleted_at IS NULL' for soft-delete tables unless apply_soft_delete is "
|
||||
@@ -1636,6 +1641,7 @@ ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
|
||||
@@ -55,6 +55,8 @@ CONFIRM_REQUIRED = {
|
||||
"notification_reset",
|
||||
"gateway_provider_delete",
|
||||
"gateway_model_delete",
|
||||
"email_account_delete",
|
||||
"email_delete_message",
|
||||
}
|
||||
|
||||
CONDITIONAL_CONFIRM = {
|
||||
@@ -103,6 +105,14 @@ _DEVII_MECHANIC_EVENTS = {
|
||||
"customize_reset": "devii.customization.reset",
|
||||
"notification_set": "devii.notification.set",
|
||||
"notification_reset": "devii.notification.reset",
|
||||
"email_account_set": "email.account.set",
|
||||
"email_account_delete": "email.account.delete",
|
||||
"email_send": "email.send",
|
||||
"email_delete_message": "email.message.delete",
|
||||
"email_move_message": "email.message.move",
|
||||
"email_mark": "email.message.flag",
|
||||
"email_set_flags": "email.message.flag",
|
||||
"telegram_send": "telegram.send",
|
||||
}
|
||||
|
||||
_DEVII_CONTAINER_EVENTS = {
|
||||
@@ -216,6 +226,19 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
|
||||
f"such as rm, dd, truncate, or drop): {command!r}. Show the user the exact command, get "
|
||||
"explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name == "email_account_delete":
|
||||
label = str(arguments.get("account", "")).strip() or "(unspecified)"
|
||||
return ToolInputError(
|
||||
f"Deleting the saved email account '{label}' removes its stored connection settings. "
|
||||
"Ask the user to confirm explicitly, then call again with confirm=true."
|
||||
)
|
||||
if name == "email_delete_message":
|
||||
uid = str(arguments.get("uid", "")).strip() or "(unspecified)"
|
||||
return ToolInputError(
|
||||
f"Deleting message {uid} on the remote mailbox is permanent unless a Trash folder is "
|
||||
"given. Show the user the exact message, get explicit confirmation, then call again "
|
||||
"with confirm=true."
|
||||
)
|
||||
if name in CONFIRM_REQUIRED:
|
||||
return ToolInputError(
|
||||
"This removes the item as a soft delete: it disappears from every surface and is only "
|
||||
@@ -236,6 +259,7 @@ class Dispatcher:
|
||||
avatar: AvatarController | None = None,
|
||||
browser: Any = None,
|
||||
is_admin: bool = False,
|
||||
is_primary_admin: bool = False,
|
||||
quota_provider: Any = None,
|
||||
owner_kind: str = "guest",
|
||||
owner_id: str = "",
|
||||
@@ -250,6 +274,7 @@ class Dispatcher:
|
||||
self._avatar = avatar
|
||||
self._browser = browser
|
||||
self._is_admin = is_admin
|
||||
self._is_primary_admin = is_primary_admin
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
self._fetch = FetchController(settings)
|
||||
@@ -272,6 +297,12 @@ class Dispatcher:
|
||||
from ..ai_modifier import AiModifierController
|
||||
|
||||
self._ai_modifier = AiModifierController(owner_kind, owner_id)
|
||||
from ..email import EmailController
|
||||
|
||||
self._email = EmailController(settings, owner_kind, owner_id)
|
||||
from ..telegram import TelegramSendController
|
||||
|
||||
self._telegram = TelegramSendController(owner_kind, owner_id)
|
||||
self._virtual_tools = virtual_tools
|
||||
self._behavior = behavior
|
||||
self._read_files: set[tuple[str, str]] = set()
|
||||
@@ -322,6 +353,11 @@ class Dispatcher:
|
||||
"This information is restricted to administrators.",
|
||||
tool=name,
|
||||
)
|
||||
if action.requires_primary_admin and not self._is_primary_admin:
|
||||
raise AuthRequiredError(
|
||||
"This tool is restricted to the primary administrator.",
|
||||
tool=name,
|
||||
)
|
||||
guard = confirmation_error(name, arguments)
|
||||
if guard is not None:
|
||||
raise guard
|
||||
@@ -427,6 +463,12 @@ class Dispatcher:
|
||||
if action.handler == "ai_modifier":
|
||||
return await self._ai_modifier.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "email":
|
||||
return await self._email.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "telegram":
|
||||
return await self._telegram.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "behavior":
|
||||
if self._behavior is None:
|
||||
return error_result(
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
REMOTE_NOTE = (
|
||||
"This reaches the user's OWN external mailbox over IMAP/SMTP, not this platform. The "
|
||||
"account must be configured first with email_account_set."
|
||||
)
|
||||
|
||||
|
||||
def arg(
|
||||
name: str, description: str, required: bool = False, kind: str = "string"
|
||||
) -> Param:
|
||||
return Param(
|
||||
name=name,
|
||||
location="body",
|
||||
description=description,
|
||||
required=required,
|
||||
type=kind,
|
||||
)
|
||||
|
||||
|
||||
ACCOUNT = arg(
|
||||
"account",
|
||||
"The label of the configured email account to use (see email_accounts_list).",
|
||||
required=True,
|
||||
)
|
||||
FOLDER = arg("folder", "Mailbox folder; defaults to INBOX when omitted.")
|
||||
UID = arg("uid", "The message UID returned by email_list_messages / email_search.", required=True)
|
||||
FILTERS = (
|
||||
arg("unseen", "Only unread messages.", kind="boolean"),
|
||||
arg("seen", "Only read messages.", kind="boolean"),
|
||||
arg("flagged", "Only flagged messages.", kind="boolean"),
|
||||
arg("from", "Match the sender address or name."),
|
||||
arg("subject", "Match text in the subject."),
|
||||
arg("since", "Only messages on or after this date, formatted DD-Mon-YYYY (e.g. 01-Jan-2026)."),
|
||||
arg("text", "Match text anywhere in the message."),
|
||||
)
|
||||
|
||||
EMAIL_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="email_accounts_list",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="List the user's configured email accounts (passwords are never returned)",
|
||||
description="Returns each saved account's connection settings without the password.",
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="email_account_get",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Read one configured email account's connection settings",
|
||||
description="Returns the account settings without the password (password_set indicates whether one is stored).",
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
params=(ACCOUNT,),
|
||||
),
|
||||
Action(
|
||||
name="email_account_set",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Create or update an email account connection (IMAP + SMTP)",
|
||||
description=(
|
||||
"Saves an IMAP/SMTP connection under a label. Sensible defaults are applied when a "
|
||||
"field is omitted: imap_port 993 with imap_ssl on, smtp_port 587 with smtp_starttls on, "
|
||||
"from_address falls back to username. Provide host, username and password at minimum. "
|
||||
+ REMOTE_NOTE
|
||||
),
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
ACCOUNT,
|
||||
arg("imap_host", "IMAP server hostname (e.g. imap.gmail.com)."),
|
||||
arg("imap_port", "IMAP port (default 993).", kind="integer"),
|
||||
arg("imap_ssl", "Use implicit SSL/TLS for IMAP (default true).", kind="boolean"),
|
||||
arg("imap_starttls", "Upgrade a plain IMAP connection with STARTTLS (default false).", kind="boolean"),
|
||||
arg("smtp_host", "SMTP server hostname (e.g. smtp.gmail.com)."),
|
||||
arg("smtp_port", "SMTP port (default 587).", kind="integer"),
|
||||
arg("smtp_ssl", "Use implicit SSL/TLS for SMTP (default false; set true for port 465).", kind="boolean"),
|
||||
arg("smtp_starttls", "Upgrade a plain SMTP connection with STARTTLS (default true).", kind="boolean"),
|
||||
arg("username", "Login username (usually the full email address)."),
|
||||
arg("password", "Login password or app-specific password."),
|
||||
arg("from_address", "From address for sent mail (defaults to the username)."),
|
||||
arg("from_name", "Display name for sent mail."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="email_account_delete",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Delete a saved email account connection (confirmation required)",
|
||||
description="Removes the stored connection. Does not touch the remote mailbox.",
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
ACCOUNT,
|
||||
arg(
|
||||
"confirm",
|
||||
"Set to true only after the user has confirmed the deletion.",
|
||||
required=True,
|
||||
kind="boolean",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="email_list_folders",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="List the mailbox folders on the account",
|
||||
description=REMOTE_NOTE,
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
params=(ACCOUNT,),
|
||||
),
|
||||
Action(
|
||||
name="email_list_messages",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="List messages in a folder, newest first, with optional filters",
|
||||
description=(
|
||||
"Returns message summaries (uid, from, subject, date, flags). Use the filters to narrow "
|
||||
"the set and limit/offset to page. " + REMOTE_NOTE
|
||||
),
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
params=(
|
||||
ACCOUNT,
|
||||
FOLDER,
|
||||
*FILTERS,
|
||||
arg("limit", "Maximum messages to return (1-100, default 25).", kind="integer"),
|
||||
arg("offset", "Number of messages to skip for paging (default 0).", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="email_read_message",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Read the full body, headers and attachment list of one message",
|
||||
description="Fetches the decoded text/html body and attachment metadata. " + REMOTE_NOTE,
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
params=(ACCOUNT, FOLDER, UID),
|
||||
),
|
||||
Action(
|
||||
name="email_search",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Search a folder by sender, subject, date, text or read/flag state",
|
||||
description="Returns matching message summaries. " + REMOTE_NOTE,
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
params=(
|
||||
ACCOUNT,
|
||||
FOLDER,
|
||||
*FILTERS,
|
||||
arg("limit", "Maximum messages to return (1-100, default 25).", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="email_mark",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Mark a message read, unread, flagged or unflagged",
|
||||
description="Convenience wrapper over the IMAP \\Seen and \\Flagged flags. " + REMOTE_NOTE,
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
ACCOUNT,
|
||||
FOLDER,
|
||||
UID,
|
||||
arg("state", "One of: read, unread, flagged, unflagged.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="email_set_flags",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Add or remove arbitrary IMAP flags on a message",
|
||||
description="For advanced use; prefer email_mark for read/flagged. " + REMOTE_NOTE,
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
ACCOUNT,
|
||||
FOLDER,
|
||||
UID,
|
||||
arg("flags", "Comma-separated IMAP flags (e.g. \\Seen, \\Flagged, \\Answered).", required=True),
|
||||
arg("add", "true to add the flags, false to remove them (default true).", kind="boolean"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="email_move_message",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Move a message to another folder",
|
||||
description="Copies the message to the destination folder and removes it from the source. " + REMOTE_NOTE,
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
ACCOUNT,
|
||||
FOLDER,
|
||||
UID,
|
||||
arg("destination", "Destination folder name.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="email_delete_message",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Delete a message (confirmation required)",
|
||||
description=(
|
||||
"Deletes the message. If 'trash' is given the message is moved there; otherwise it is "
|
||||
"flagged \\Deleted and expunged. " + REMOTE_NOTE
|
||||
),
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
ACCOUNT,
|
||||
FOLDER,
|
||||
UID,
|
||||
arg("trash", "Optional Trash folder to move the message into instead of expunging."),
|
||||
arg(
|
||||
"confirm",
|
||||
"Set to true only after the user has confirmed the deletion.",
|
||||
required=True,
|
||||
kind="boolean",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="email_send",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Send an email via the account's SMTP server",
|
||||
description="Composes and sends a message. " + REMOTE_NOTE,
|
||||
handler="email",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
ACCOUNT,
|
||||
arg("to", "Recipient address or comma-separated list.", required=True),
|
||||
arg("subject", "Email subject."),
|
||||
arg("body", "Plain-text body."),
|
||||
arg("cc", "CC recipients (comma-separated)."),
|
||||
arg("bcc", "BCC recipients (comma-separated)."),
|
||||
arg("html", "Optional HTML alternative body."),
|
||||
arg("in_reply_to", "Optional Message-ID this email replies to."),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -27,6 +27,7 @@ class Action:
|
||||
params: tuple[Param, ...] = ()
|
||||
requires_auth: bool = True
|
||||
requires_admin: bool = False
|
||||
requires_primary_admin: bool = False
|
||||
handler: Literal[
|
||||
"http",
|
||||
"login",
|
||||
@@ -46,6 +47,8 @@ class Action:
|
||||
"notification",
|
||||
"behavior",
|
||||
"virtual_tool",
|
||||
"email",
|
||||
"telegram",
|
||||
] = "http"
|
||||
freeform_body: bool = False
|
||||
ajax: bool = False
|
||||
@@ -115,11 +118,15 @@ class Catalog:
|
||||
return [action.tool_schema() for action in self.actions]
|
||||
|
||||
def tool_schemas_for(
|
||||
self, authenticated: bool, is_admin: bool = False
|
||||
self,
|
||||
authenticated: bool,
|
||||
is_admin: bool = False,
|
||||
is_primary_admin: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
action.tool_schema()
|
||||
for action in self.actions
|
||||
if (authenticated or not action.requires_auth)
|
||||
and (is_admin or not action.requires_admin)
|
||||
and (is_primary_admin or not action.requires_primary_admin)
|
||||
]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
TELEGRAM_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="telegram_send",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Send a message to the user's connected Telegram chat",
|
||||
description=(
|
||||
"Delivers a markdown message to the signed-in user's paired Telegram account. Use this "
|
||||
"to push notifications, reminders, or results to the user on Telegram, including from a "
|
||||
"scheduled task. Requires the user to have paired Telegram from their profile and the "
|
||||
"Telegram bot service to be running. Markdown is rendered to Telegram formatting and "
|
||||
"long messages are split automatically."
|
||||
),
|
||||
handler="telegram",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
Param(
|
||||
name="text",
|
||||
location="body",
|
||||
description="The markdown message to deliver to the user's Telegram chat.",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -53,8 +53,25 @@ SYSTEM_PROMPT = (
|
||||
"highlight_element, show_toast, scroll_to_element and clear_highlights guide them with live, "
|
||||
"on-screen tutorials; run_js executes JavaScript and returns a value. The session and this "
|
||||
"conversation persist and reconnect automatically across navigation. "
|
||||
"Confirm destructive actions with the user first. Schedule autonomous work with "
|
||||
"create_task and related tools. All times are UTC.\n\n"
|
||||
"Confirm destructive actions with the user first.\n\n"
|
||||
"REMINDERS AND SCHEDULED TASKS\n"
|
||||
"When the user asks you to remind, tell, notify, ping, wake, or alert them after a delay or "
|
||||
"at a time ('remind me in 40 seconds', 'tell me to go upstairs in 2 minutes', 'every "
|
||||
"morning at 9 post the news'), DO NOT answer or perform the instruction immediately. CREATE "
|
||||
"A SCHEDULED TASK with create_task so it fires later, then confirm the schedule. A request "
|
||||
"with a delay or a time is never something to do right now - it is something to schedule. "
|
||||
"Choose the schedule precisely: a RELATIVE request ('in N seconds/minutes/hours') uses "
|
||||
"kind='once' with delay_seconds; an ABSOLUTE wall-clock time ('at 3pm', 'tomorrow 09:00') "
|
||||
"uses kind='once' with run_at in UTC, converting from the user's local timezone shown in the "
|
||||
"CURRENT TIME section; a RECURRING request ('every day', 'each Monday') uses kind='cron'. "
|
||||
"The prompt you store is what a fresh agent runs when the task fires and it has no other "
|
||||
"context, so write a complete self-contained instruction (for a reminder: 'Tell the user to "
|
||||
"go upstairs now.'). For any reminder, pass notify=true so the user is sent an in-app "
|
||||
"notification and toast carrying your message even if the Devii terminal is closed. After "
|
||||
"creating it, tell the user exactly when it will fire, in their local time. Manage existing "
|
||||
"reminders with list_tasks, get_task, update_task, run_task_now, and delete_task. Times in "
|
||||
"task records are UTC; the CURRENT TIME section gives you the current UTC time and the "
|
||||
"user's local time and timezone for conversion.\n\n"
|
||||
"DRIVING THE BROWSER (the reliable flow)\n"
|
||||
"To act on the user's screen, follow this loop instead of guessing selectors or writing run_js. "
|
||||
"(1) get_page_context to see where they are, the open modal, and visible toasts. "
|
||||
@@ -175,6 +192,15 @@ SYSTEM_PROMPT = (
|
||||
"information the platform cannot provide and the user wants it. Never use them to answer questions "
|
||||
"about this DevPlace instance, its users, posts, settings, or metrics - those have dedicated "
|
||||
"platform tools. When platform tools can serve the request, do not call rsearch.\n\n"
|
||||
"EMAIL TOOLS (email_*)\n"
|
||||
"The email_* tools connect to the user's OWN external mailbox over IMAP/SMTP, not this "
|
||||
"platform, and are only available to signed-in users. The connection must be configured first "
|
||||
"with email_account_set under a label (host, port, username, password; sensible defaults fill "
|
||||
"the rest), and every other email tool takes that label as its 'account'. Use email_list_messages "
|
||||
"/ email_search / email_read_message to read, email_mark / email_move_message to organise, and "
|
||||
"email_send to send. Sending is a real outbound action: show the user the recipients, subject and "
|
||||
"body and get their go-ahead before calling email_send. Deleting a message or removing a saved "
|
||||
"account is confirmation-gated (call again with confirm=true only after the user agrees).\n\n"
|
||||
"CUSTOMIZATION (CSS AND JAVASCRIPT)\n"
|
||||
"The user can permanently customize the LOOK (custom CSS) and BEHAVIOUR (custom JavaScript) of "
|
||||
"the site through the customize_* tools. Every customization is scoped either to the current "
|
||||
@@ -286,7 +312,7 @@ class Agent:
|
||||
{"role": "system", "content": system_prompt}
|
||||
]
|
||||
|
||||
async def respond(self, user_text: str) -> str:
|
||||
async def respond(self, user_text: Any) -> str:
|
||||
self._inject_recalled_lessons(user_text)
|
||||
self._messages.append({"role": "user", "content": user_text})
|
||||
state = AgentState()
|
||||
@@ -305,7 +331,9 @@ class Agent:
|
||||
chunk_store=self._chunk_store,
|
||||
)
|
||||
|
||||
def _inject_recalled_lessons(self, user_text: str) -> None:
|
||||
def _inject_recalled_lessons(self, user_text: Any) -> None:
|
||||
if not isinstance(user_text, str):
|
||||
return
|
||||
if self._lessons is None or self._lessons.count() == 0:
|
||||
return
|
||||
hits = self._lessons.search(user_text, k=self._settings.recall_top_k)
|
||||
|
||||
@@ -167,9 +167,17 @@ async def run(settings: Settings, prompt: Optional[str] = None) -> None:
|
||||
chunk_store = ChunkStore()
|
||||
agentic = AgenticController(lessons, settings)
|
||||
dispatcher = Dispatcher(
|
||||
CATALOG, client, settings, controller, agentic, is_admin=True
|
||||
CATALOG,
|
||||
client,
|
||||
settings,
|
||||
controller,
|
||||
agentic,
|
||||
is_admin=True,
|
||||
is_primary_admin=True,
|
||||
)
|
||||
tools = CATALOG.tool_schemas_for(
|
||||
client.authenticated, is_admin=True, is_primary_admin=True
|
||||
)
|
||||
tools = CATALOG.tool_schemas_for(client.authenticated, is_admin=True)
|
||||
agentic.bind(
|
||||
llm=llm,
|
||||
dispatcher=dispatcher,
|
||||
|
||||
@@ -38,6 +38,7 @@ DEFAULT_FETCH_TIMEOUT_SECONDS = 300.0
|
||||
DEFAULT_FETCH_MAX_BYTES = 8_000_000
|
||||
DEFAULT_RSEARCH_URL = "https://rsearch.app.molodetz.nl"
|
||||
DEFAULT_RSEARCH_TIMEOUT_SECONDS = 300.0
|
||||
DEFAULT_EMAIL_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -72,6 +73,8 @@ class Settings:
|
||||
rsearch_enabled: bool
|
||||
rsearch_url: str
|
||||
rsearch_timeout_seconds: float
|
||||
email_enabled: bool
|
||||
email_timeout_seconds: float
|
||||
daily_limit_usd: float
|
||||
|
||||
|
||||
@@ -152,6 +155,11 @@ def load_settings() -> Settings:
|
||||
rsearch_timeout_seconds=float(
|
||||
os.environ.get("DEVII_RSEARCH_TIMEOUT", DEFAULT_RSEARCH_TIMEOUT_SECONDS)
|
||||
),
|
||||
email_enabled=os.environ.get("DEVII_EMAIL_ENABLED", "1").lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
email_timeout_seconds=float(
|
||||
os.environ.get("DEVII_EMAIL_TIMEOUT", DEFAULT_EMAIL_TIMEOUT_SECONDS)
|
||||
),
|
||||
daily_limit_usd=0.0,
|
||||
)
|
||||
|
||||
@@ -176,6 +184,8 @@ FIELD_FETCH_TIMEOUT = "devii_fetch_timeout"
|
||||
FIELD_RSEARCH_ENABLED = "devii_rsearch_enabled"
|
||||
FIELD_RSEARCH_URL = "devii_rsearch_url"
|
||||
FIELD_RSEARCH_TIMEOUT = "devii_rsearch_timeout"
|
||||
FIELD_EMAIL_ENABLED = "devii_email_enabled"
|
||||
FIELD_EMAIL_TIMEOUT = "devii_email_timeout"
|
||||
|
||||
LESSONS_DB_PATH = os.environ.get("DEVII_LESSONS_DB", str(DEVII_LESSONS_DB))
|
||||
|
||||
@@ -234,5 +244,9 @@ def build_settings(
|
||||
rsearch_timeout_seconds=float(
|
||||
config.get(FIELD_RSEARCH_TIMEOUT) or DEFAULT_RSEARCH_TIMEOUT_SECONDS
|
||||
),
|
||||
email_enabled=bool(config.get(FIELD_EMAIL_ENABLED, True)),
|
||||
email_timeout_seconds=float(
|
||||
config.get(FIELD_EMAIL_TIMEOUT) or DEFAULT_EMAIL_TIMEOUT_SECONDS
|
||||
),
|
||||
daily_limit_usd=effective_daily_limit(config, owner_kind, is_admin),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .controller import CostController
|
||||
from .tracker import CostTracker, Pricing, record_usage, reset_tracker, set_tracker
|
||||
from .tracker import (
|
||||
CostTracker,
|
||||
Pricing,
|
||||
record_cost,
|
||||
record_usage,
|
||||
reset_tracker,
|
||||
set_tracker,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CostTracker",
|
||||
"Pricing",
|
||||
"CostController",
|
||||
"record_usage",
|
||||
"record_cost",
|
||||
"set_tracker",
|
||||
"reset_tracker",
|
||||
]
|
||||
|
||||
@@ -48,6 +48,12 @@ class CostTracker:
|
||||
cache_hit_tokens: int = 0
|
||||
cache_miss_tokens: int = 0
|
||||
reasoning_tokens: int = 0
|
||||
native_cost_usd: float = 0.0
|
||||
has_native: bool = False
|
||||
|
||||
def record_native(self, cost_usd: float) -> None:
|
||||
self.native_cost_usd += float(cost_usd or 0.0)
|
||||
self.has_native = True
|
||||
|
||||
def record(self, usage: Optional[dict[str, Any]]) -> None:
|
||||
if not usage:
|
||||
@@ -91,7 +97,11 @@ class CostTracker:
|
||||
self.cache_miss_tokens / PER_MILLION * self.pricing.cache_miss_per_m
|
||||
)
|
||||
output = self.completion_tokens / PER_MILLION * self.pricing.output_per_m
|
||||
total = cache_hit + cache_miss + output
|
||||
total = (
|
||||
self.native_cost_usd
|
||||
if self.has_native
|
||||
else (cache_hit + cache_miss + output)
|
||||
)
|
||||
return {
|
||||
"cache_hit_input": round(cache_hit, 8),
|
||||
"cache_miss_input": round(cache_miss, 8),
|
||||
@@ -163,3 +173,9 @@ def record_usage(usage: Optional[dict[str, Any]]) -> None:
|
||||
tracker = _active_tracker.get()
|
||||
if tracker is not None:
|
||||
tracker.record(usage)
|
||||
|
||||
|
||||
def record_cost(cost_usd: float) -> None:
|
||||
tracker = _active_tracker.get()
|
||||
if tracker is not None:
|
||||
tracker.record_native(cost_usd)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .controller import EmailController
|
||||
|
||||
__all__ = ["EmailController"]
|
||||
@@ -0,0 +1,360 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy import database
|
||||
from devplacepy.services.email import EmailAccount, EmailClient, EmailError
|
||||
from devplacepy.services.email.client import imap_quote
|
||||
|
||||
from ..config import Settings
|
||||
from ..errors import AuthRequiredError, ToolInputError, UpstreamError
|
||||
|
||||
logger = logging.getLogger("devii.email")
|
||||
|
||||
ACCOUNT_FIELDS = (
|
||||
"imap_host",
|
||||
"imap_port",
|
||||
"imap_ssl",
|
||||
"imap_starttls",
|
||||
"smtp_host",
|
||||
"smtp_port",
|
||||
"smtp_ssl",
|
||||
"smtp_starttls",
|
||||
"username",
|
||||
"password",
|
||||
"from_address",
|
||||
"from_name",
|
||||
)
|
||||
BOOLEAN_FIELDS = ("imap_ssl", "imap_starttls", "smtp_ssl", "smtp_starttls")
|
||||
INTEGER_FIELDS = ("imap_port", "smtp_port")
|
||||
MARK_FLAGS = {
|
||||
"read": ("\\Seen", True),
|
||||
"unread": ("\\Seen", False),
|
||||
"flagged": ("\\Flagged", True),
|
||||
"unflagged": ("\\Flagged", False),
|
||||
}
|
||||
|
||||
|
||||
def _flag(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _text(arguments: dict[str, Any], key: str) -> str:
|
||||
return str(arguments.get(key, "") or "").strip()
|
||||
|
||||
|
||||
def _address_list(value: Any) -> list[str]:
|
||||
if isinstance(value, (list, tuple)):
|
||||
items = [str(item).strip() for item in value]
|
||||
else:
|
||||
items = [chunk.strip() for chunk in str(value or "").split(",")]
|
||||
return [item for item in items if item]
|
||||
|
||||
|
||||
class EmailController:
|
||||
def __init__(
|
||||
self, settings: Settings, owner_kind: str = "guest", owner_id: str = ""
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if not getattr(self._settings, "email_enabled", True):
|
||||
raise ToolInputError(
|
||||
"Email tools are disabled by the administrator."
|
||||
)
|
||||
if self._owner_kind != "user":
|
||||
raise AuthRequiredError(
|
||||
"Email tools are only available to signed-in users.", tool=name
|
||||
)
|
||||
if name == "email_accounts_list":
|
||||
return self._accounts_list()
|
||||
if name == "email_account_get":
|
||||
return self._account_get(arguments)
|
||||
if name == "email_account_set":
|
||||
return self._account_set(arguments)
|
||||
if name == "email_account_delete":
|
||||
return self._account_delete(arguments)
|
||||
if name == "email_list_folders":
|
||||
return await self._list_folders(arguments)
|
||||
if name == "email_list_messages":
|
||||
return await self._list_messages(arguments)
|
||||
if name == "email_read_message":
|
||||
return await self._read_message(arguments)
|
||||
if name == "email_search":
|
||||
return await self._search(arguments)
|
||||
if name == "email_set_flags":
|
||||
return await self._set_flags(arguments)
|
||||
if name == "email_mark":
|
||||
return await self._mark(arguments)
|
||||
if name == "email_move_message":
|
||||
return await self._move(arguments)
|
||||
if name == "email_delete_message":
|
||||
return await self._delete(arguments)
|
||||
if name == "email_send":
|
||||
return await self._send(arguments)
|
||||
raise ToolInputError(f"Unknown email tool: {name}")
|
||||
|
||||
def _label(self, arguments: dict[str, Any]) -> str:
|
||||
label = _text(arguments, "account")
|
||||
if not label:
|
||||
raise ToolInputError("An 'account' label is required.")
|
||||
return label
|
||||
|
||||
def _client(self, arguments: dict[str, Any]) -> EmailClient:
|
||||
label = self._label(arguments)
|
||||
row = database.get_email_account(self._owner_kind, self._owner_id, label)
|
||||
if not row:
|
||||
raise ToolInputError(
|
||||
f"No email account named '{label}'. Add it with email_account_set first."
|
||||
)
|
||||
return EmailClient(
|
||||
EmailAccount.from_row(row),
|
||||
timeout=getattr(self._settings, "email_timeout_seconds", 30.0),
|
||||
)
|
||||
|
||||
def _public_account(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"label": row.get("label"),
|
||||
"imap_host": row.get("imap_host"),
|
||||
"imap_port": row.get("imap_port"),
|
||||
"imap_ssl": bool(row.get("imap_ssl")),
|
||||
"imap_starttls": bool(row.get("imap_starttls")),
|
||||
"smtp_host": row.get("smtp_host"),
|
||||
"smtp_port": row.get("smtp_port"),
|
||||
"smtp_ssl": bool(row.get("smtp_ssl")),
|
||||
"smtp_starttls": bool(row.get("smtp_starttls")),
|
||||
"username": row.get("username"),
|
||||
"password_set": bool(row.get("password")),
|
||||
"from_address": row.get("from_address"),
|
||||
"from_name": row.get("from_name"),
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
|
||||
def _accounts_list(self) -> str:
|
||||
rows = database.list_email_accounts(self._owner_kind, self._owner_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"accounts": [self._public_account(row) for row in rows],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _account_get(self, arguments: dict[str, Any]) -> str:
|
||||
label = self._label(arguments)
|
||||
row = database.get_email_account(self._owner_kind, self._owner_id, label)
|
||||
if not row:
|
||||
raise ToolInputError(f"No email account named '{label}'.")
|
||||
return json.dumps(
|
||||
{"status": "success", "account": self._public_account(row)},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _account_set(self, arguments: dict[str, Any]) -> str:
|
||||
label = self._label(arguments)
|
||||
fields: dict[str, Any] = {}
|
||||
for key in ACCOUNT_FIELDS:
|
||||
if key not in arguments or arguments[key] is None:
|
||||
continue
|
||||
value = arguments[key]
|
||||
if key in BOOLEAN_FIELDS:
|
||||
fields[key] = 1 if _flag(value) else 0
|
||||
elif key in INTEGER_FIELDS:
|
||||
try:
|
||||
fields[key] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ToolInputError(f"'{key}' must be an integer port number.")
|
||||
else:
|
||||
fields[key] = str(value).strip()
|
||||
row = database.set_email_account(
|
||||
self._owner_kind, self._owner_id, label, fields
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"saved": True,
|
||||
"account": self._public_account(row),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _account_delete(self, arguments: dict[str, Any]) -> str:
|
||||
label = self._label(arguments)
|
||||
removed = database.delete_email_account(
|
||||
self._owner_kind,
|
||||
self._owner_id,
|
||||
label,
|
||||
deleted_by=f"user:{self._owner_id}",
|
||||
)
|
||||
return json.dumps(
|
||||
{"status": "success", "removed": int(removed), "account": label},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _folder(self, arguments: dict[str, Any]) -> str:
|
||||
return _text(arguments, "folder") or "INBOX"
|
||||
|
||||
def _limit(self, arguments: dict[str, Any], default: int = 25) -> int:
|
||||
try:
|
||||
limit = int(arguments.get("limit", default))
|
||||
except (TypeError, ValueError):
|
||||
limit = default
|
||||
return max(1, min(limit, 100))
|
||||
|
||||
def _offset(self, arguments: dict[str, Any]) -> int:
|
||||
try:
|
||||
offset = int(arguments.get("offset", 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
return max(0, offset)
|
||||
|
||||
def _criteria(self, arguments: dict[str, Any]) -> list[str]:
|
||||
criteria: list[str] = []
|
||||
if _flag(arguments.get("unseen")):
|
||||
criteria.append("UNSEEN")
|
||||
if _flag(arguments.get("seen")):
|
||||
criteria.append("SEEN")
|
||||
if _flag(arguments.get("flagged")):
|
||||
criteria.append("FLAGGED")
|
||||
sender = _text(arguments, "from")
|
||||
if sender:
|
||||
criteria += ["FROM", imap_quote(sender)]
|
||||
subject = _text(arguments, "subject")
|
||||
if subject:
|
||||
criteria += ["SUBJECT", imap_quote(subject)]
|
||||
since = _text(arguments, "since")
|
||||
if since:
|
||||
criteria += ["SINCE", since]
|
||||
text = _text(arguments, "text")
|
||||
if text:
|
||||
criteria += ["TEXT", imap_quote(text)]
|
||||
return criteria
|
||||
|
||||
async def _run(self, func: Any, *args: Any) -> Any:
|
||||
try:
|
||||
return await asyncio.to_thread(func, *args)
|
||||
except EmailError as exc:
|
||||
if exc.kind in ("config", "not_found", "blocked"):
|
||||
raise ToolInputError(exc.message) from exc
|
||||
raise UpstreamError(exc.message) from exc
|
||||
|
||||
def _ok(self, payload: dict[str, Any]) -> str:
|
||||
return json.dumps({"status": "success", **payload}, ensure_ascii=False)
|
||||
|
||||
async def _list_folders(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
folders = await self._run(client.list_folders)
|
||||
return self._ok({"folders": folders})
|
||||
|
||||
async def _list_messages(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
result = await self._run(
|
||||
client.list_messages,
|
||||
self._folder(arguments),
|
||||
self._criteria(arguments),
|
||||
self._limit(arguments),
|
||||
self._offset(arguments),
|
||||
)
|
||||
return self._ok(result)
|
||||
|
||||
async def _read_message(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
uid = _text(arguments, "uid")
|
||||
if not uid:
|
||||
raise ToolInputError("A message 'uid' is required.")
|
||||
result = await self._run(
|
||||
client.read_message, self._folder(arguments), uid
|
||||
)
|
||||
return self._ok({"message": result})
|
||||
|
||||
async def _search(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
result = await self._run(
|
||||
client.search,
|
||||
self._folder(arguments),
|
||||
self._criteria(arguments),
|
||||
self._limit(arguments),
|
||||
)
|
||||
return self._ok(result)
|
||||
|
||||
async def _set_flags(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
uid = _text(arguments, "uid")
|
||||
if not uid:
|
||||
raise ToolInputError("A message 'uid' is required.")
|
||||
flags = _address_list(arguments.get("flags"))
|
||||
if not flags:
|
||||
raise ToolInputError("At least one flag is required (e.g. \\Seen).")
|
||||
add = _flag(arguments.get("add", True)) if "add" in arguments else True
|
||||
result = await self._run(
|
||||
client.set_flags, self._folder(arguments), uid, flags, add
|
||||
)
|
||||
return self._ok(result)
|
||||
|
||||
async def _mark(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
uid = _text(arguments, "uid")
|
||||
if not uid:
|
||||
raise ToolInputError("A message 'uid' is required.")
|
||||
state = _text(arguments, "state").lower()
|
||||
if state not in MARK_FLAGS:
|
||||
raise ToolInputError(
|
||||
"'state' must be one of: read, unread, flagged, unflagged."
|
||||
)
|
||||
flag, add = MARK_FLAGS[state]
|
||||
result = await self._run(
|
||||
client.set_flags, self._folder(arguments), uid, [flag], add
|
||||
)
|
||||
return self._ok({**result, "state": state})
|
||||
|
||||
async def _move(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
uid = _text(arguments, "uid")
|
||||
destination = _text(arguments, "destination")
|
||||
if not uid or not destination:
|
||||
raise ToolInputError("Both 'uid' and 'destination' are required.")
|
||||
result = await self._run(
|
||||
client.move_message, self._folder(arguments), uid, destination
|
||||
)
|
||||
return self._ok(result)
|
||||
|
||||
async def _delete(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
uid = _text(arguments, "uid")
|
||||
if not uid:
|
||||
raise ToolInputError("A message 'uid' is required.")
|
||||
trash = _text(arguments, "trash") or None
|
||||
result = await self._run(
|
||||
client.delete_message, self._folder(arguments), uid, trash
|
||||
)
|
||||
return self._ok(result)
|
||||
|
||||
async def _send(self, arguments: dict[str, Any]) -> str:
|
||||
client = self._client(arguments)
|
||||
to = _address_list(arguments.get("to"))
|
||||
if not to:
|
||||
raise ToolInputError("At least one 'to' recipient is required.")
|
||||
subject = _text(arguments, "subject")
|
||||
body = str(arguments.get("body", "") or "")
|
||||
if not subject and not body:
|
||||
raise ToolInputError("A 'subject' or 'body' is required.")
|
||||
result = await self._run(
|
||||
client.send_message,
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
_address_list(arguments.get("cc")),
|
||||
_address_list(arguments.get("bcc")),
|
||||
str(arguments.get("html", "") or "") or None,
|
||||
_text(arguments, "in_reply_to") or None,
|
||||
)
|
||||
return self._ok(result)
|
||||
@@ -44,6 +44,7 @@ class DeviiHub:
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
is_admin: bool = False,
|
||||
is_primary_admin: bool = False,
|
||||
channel: str = "main",
|
||||
) -> DeviiSession:
|
||||
key = (owner_kind, owner_id, channel)
|
||||
@@ -57,7 +58,11 @@ class DeviiHub:
|
||||
# ephemeral stores so Devii's tasks, lessons, behavior and virtual tools never leak into it
|
||||
# (and a Docii reflection never pollutes the user's Devii memory). Only the conversation
|
||||
# thread persists, keyed per channel in the shared ConversationStore.
|
||||
owned_db = db if (owner_kind == "user" and channel == "main") else memory_db()
|
||||
owned_db = (
|
||||
db
|
||||
if (owner_kind == "user" and channel in ("main", "telegram"))
|
||||
else memory_db()
|
||||
)
|
||||
task_store = TaskStore(owned_db, owner_kind, owner_id)
|
||||
lessons = LessonStore(owned_db, owner_kind, owner_id)
|
||||
virtual_tool_store = VirtualToolStore(owned_db, owner_kind, owner_id)
|
||||
@@ -75,6 +80,7 @@ class DeviiHub:
|
||||
behavior_store,
|
||||
self._stores,
|
||||
is_admin=is_admin,
|
||||
is_primary_admin=is_primary_admin,
|
||||
channel=channel,
|
||||
)
|
||||
if owner_kind == "user":
|
||||
@@ -105,7 +111,7 @@ class DeviiHub:
|
||||
async def gc_idle(self) -> int:
|
||||
removed = 0
|
||||
for key, session in list(self._sessions.items()):
|
||||
if session.connection_count == 0:
|
||||
if session.connection_count == 0 and not session.has_pending_tasks():
|
||||
await session.aclose()
|
||||
del self._sessions[key]
|
||||
removed += 1
|
||||
|
||||
@@ -9,7 +9,7 @@ import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from .config import Settings
|
||||
from .cost import record_usage
|
||||
from .cost import record_cost, record_usage
|
||||
from .errors import LLMError
|
||||
|
||||
logger = logging.getLogger("devii.llm")
|
||||
@@ -67,6 +67,7 @@ class LLMClient:
|
||||
raise LLMError("Model response contained no message.", body=str(data)[:500])
|
||||
|
||||
record_usage(data.get("usage"))
|
||||
self._record_native_cost(response)
|
||||
logger.debug(
|
||||
"LLM response received (tool_calls=%s)", bool(message.get("tool_calls"))
|
||||
)
|
||||
@@ -94,6 +95,7 @@ class LLMClient:
|
||||
except (ValueError, KeyError, IndexError) as exc:
|
||||
raise LLMError("Model endpoint returned an unexpected response.") from exc
|
||||
record_usage(data.get("usage"))
|
||||
self._record_native_cost(response)
|
||||
return content
|
||||
|
||||
async def summarize(self, text: str) -> str:
|
||||
@@ -124,8 +126,19 @@ class LLMClient:
|
||||
"Model summarization returned an unexpected response."
|
||||
) from exc
|
||||
record_usage(data.get("usage"))
|
||||
self._record_native_cost(response)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _record_native_cost(response: httpx.Response) -> None:
|
||||
raw = response.headers.get("X-Gateway-Cost-USD")
|
||||
if raw is None:
|
||||
return
|
||||
try:
|
||||
record_cost(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _reason(response: httpx.Response) -> str:
|
||||
try:
|
||||
|
||||
@@ -13,10 +13,12 @@ from .actions.container_actions import CONTAINER_ACTIONS
|
||||
from .actions.cost_actions import COST_ACTIONS
|
||||
from .actions.customization_actions import CUSTOMIZATION_ACTIONS
|
||||
from .actions.docs_actions import DOCS_ACTIONS
|
||||
from .actions.email_actions import EMAIL_ACTIONS
|
||||
from .actions.fetch_actions import FETCH_ACTIONS
|
||||
from .actions.notification_actions import NOTIFICATION_ACTIONS
|
||||
from .actions.rsearch_actions import RSEARCH_ACTIONS
|
||||
from .actions.spec import Catalog
|
||||
from .actions.telegram_actions import TELEGRAM_ACTIONS
|
||||
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
|
||||
from .agentic.actions import AGENTIC_ACTIONS
|
||||
from .tasks.actions import TASK_ACTIONS
|
||||
@@ -38,6 +40,8 @@ CATALOG = Catalog(
|
||||
+ NOTIFICATION_ACTIONS
|
||||
+ AI_CORRECTION_ACTIONS
|
||||
+ AI_MODIFIER_ACTIONS
|
||||
+ EMAIL_ACTIONS
|
||||
+ TELEGRAM_ACTIONS
|
||||
+ VIRTUAL_TOOL_ACTIONS
|
||||
)
|
||||
|
||||
|
||||
@@ -216,6 +216,25 @@ class DeviiService(BaseService):
|
||||
"minutes, so this is generous by default. Minimum five minutes.",
|
||||
group="Web search",
|
||||
),
|
||||
ConfigField(
|
||||
config.FIELD_EMAIL_ENABLED,
|
||||
"Enable email tools",
|
||||
type="bool",
|
||||
default=True,
|
||||
help="Allow the email_* tools (configure accounts, list/read/search/flag/move/delete "
|
||||
"messages, send mail) for signed-in users. These connect to the user's own external "
|
||||
"mailbox over IMAP/SMTP, not this platform. When off, those tools are refused.",
|
||||
group="Email",
|
||||
),
|
||||
ConfigField(
|
||||
config.FIELD_EMAIL_TIMEOUT,
|
||||
"Email timeout (seconds)",
|
||||
type="float",
|
||||
default=config.DEFAULT_EMAIL_TIMEOUT_SECONDS,
|
||||
minimum=1,
|
||||
help="Connection/read timeout for IMAP and SMTP calls.",
|
||||
group="Email",
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
@@ -275,6 +294,12 @@ class DeviiService(BaseService):
|
||||
def spent_24h(self, owner_kind: str, owner_id: str) -> float:
|
||||
return self.hub().ledger.spent_24h(owner_kind, owner_id)
|
||||
|
||||
def quota_exceeded(
|
||||
self, owner_kind: str, owner_id: str, is_admin: bool = False
|
||||
) -> bool:
|
||||
limit = self.daily_limit_for(owner_kind, is_admin)
|
||||
return limit > 0 and self.spent_24h(owner_kind, owner_id) >= limit
|
||||
|
||||
def reset_quota(self, owner_kind: str, owner_id: str) -> int:
|
||||
return self.hub().ledger.reset(owner_kind, owner_id)
|
||||
|
||||
@@ -288,13 +313,52 @@ class DeviiService(BaseService):
|
||||
if not self.is_enabled():
|
||||
return
|
||||
hub = self.hub()
|
||||
ensured = self._ensure_task_schedulers()
|
||||
pruned = hub.ledger.prune(48)
|
||||
removed = await hub.gc_idle()
|
||||
if pruned or removed:
|
||||
if pruned or removed or ensured:
|
||||
self.log(
|
||||
f"Housekeeping: pruned {pruned} ledger rows, closed {removed} idle sessions"
|
||||
f"Housekeeping: ensured {ensured} task scheduler(s), pruned {pruned} "
|
||||
f"ledger rows, closed {removed} idle sessions"
|
||||
)
|
||||
|
||||
def _ensure_task_schedulers(self) -> int:
|
||||
from devplacepy.database import db
|
||||
from devplacepy.utils import is_admin, is_primary_admin
|
||||
|
||||
from .tasks.store import pending_owner_ids
|
||||
|
||||
try:
|
||||
owner_ids = pending_owner_ids(db)
|
||||
except Exception: # noqa: BLE001 - a bad read must not stop housekeeping
|
||||
logger.exception("Failed to scan for owners with pending Devii tasks")
|
||||
return 0
|
||||
if not owner_ids:
|
||||
return 0
|
||||
users = db["users"] if "users" in db.tables else None
|
||||
if users is None:
|
||||
return 0
|
||||
ensured = 0
|
||||
base_url = self.instance_base_url()
|
||||
for owner_id in owner_ids:
|
||||
user = users.find_one(uid=owner_id)
|
||||
if not user:
|
||||
continue
|
||||
session = self.hub().get_or_create(
|
||||
"user",
|
||||
owner_id,
|
||||
user.get("username", ""),
|
||||
user.get("api_key", ""),
|
||||
base_url,
|
||||
is_admin=is_admin(user),
|
||||
is_primary_admin=is_primary_admin(user),
|
||||
channel="main",
|
||||
)
|
||||
session.set_timezone(user.get("timezone") or "")
|
||||
session.ensure_scheduler_started()
|
||||
ensured += 1
|
||||
return ensured
|
||||
|
||||
async def on_disable(self) -> None:
|
||||
if self._hub is not None:
|
||||
await self._hub.aclose()
|
||||
|
||||
@@ -85,6 +85,7 @@ class DeviiSession:
|
||||
behavior_store: Any,
|
||||
stores: dict[str, Any],
|
||||
is_admin: bool = False,
|
||||
is_primary_admin: bool = False,
|
||||
channel: str = "main",
|
||||
) -> None:
|
||||
self.owner_kind = owner_kind
|
||||
@@ -93,7 +94,10 @@ class DeviiSession:
|
||||
self.username = username
|
||||
self.settings = settings
|
||||
self.is_admin = is_admin
|
||||
self.is_primary_admin = is_primary_admin
|
||||
self.persist_conversation = owner_kind == "user"
|
||||
self._timezone: str = ""
|
||||
self._tz_offset_minutes: int | None = None
|
||||
self._llm = llm
|
||||
self._lessons = lessons
|
||||
self.client = PlatformClient(
|
||||
@@ -123,6 +127,7 @@ class DeviiSession:
|
||||
avatar=self.avatar,
|
||||
browser=self.browser,
|
||||
is_admin=is_admin,
|
||||
is_primary_admin=is_primary_admin,
|
||||
quota_provider=self._quota_snapshot,
|
||||
owner_kind=owner_kind,
|
||||
owner_id=owner_id,
|
||||
@@ -201,6 +206,12 @@ class DeviiSession:
|
||||
def _make_executor(self) -> Any:
|
||||
async def execute(prompt: str) -> str:
|
||||
async with self._lock:
|
||||
turn_id = uuid_utils.uuid7().hex
|
||||
started_at = _now_iso()
|
||||
self._turn_tool_calls = 0
|
||||
before = self._cost_snapshot()
|
||||
reply = ""
|
||||
error = ""
|
||||
worker = Agent(
|
||||
self.settings,
|
||||
self._llm,
|
||||
@@ -212,7 +223,16 @@ class DeviiSession:
|
||||
chunk_store=self.chunks,
|
||||
system_prompt=self._compose_system_prompt(),
|
||||
)
|
||||
return await worker.respond(prompt)
|
||||
try:
|
||||
reply = await worker.respond(prompt)
|
||||
return reply
|
||||
except Exception as exc: # noqa: BLE001 - recorded, then re-raised to the scheduler
|
||||
error = str(exc)
|
||||
raise
|
||||
finally:
|
||||
self._record_spend(
|
||||
turn_id, started_at, prompt, reply, error, before
|
||||
)
|
||||
|
||||
return execute
|
||||
|
||||
@@ -228,9 +248,7 @@ class DeviiSession:
|
||||
self._disconnected.clear()
|
||||
self.avatar.bind(self._avatar_request)
|
||||
self.browser.bind(self._client_request)
|
||||
if not self._started and self.channel == "main":
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
self.ensure_scheduler_started()
|
||||
if self._buffer:
|
||||
pending = self._buffer
|
||||
self._buffer = []
|
||||
@@ -257,6 +275,40 @@ class DeviiSession:
|
||||
len(self._conns),
|
||||
)
|
||||
|
||||
def ensure_scheduler_started(self) -> None:
|
||||
if not self._started and self.channel == "main":
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
|
||||
def has_pending_tasks(self) -> bool:
|
||||
if self.channel != "main":
|
||||
return False
|
||||
try:
|
||||
return self.store.has_pending()
|
||||
except Exception: # noqa: BLE001 - never block GC on a store read
|
||||
return False
|
||||
|
||||
def set_clientinfo(self, timezone_name: str, offset_minutes: int | None) -> None:
|
||||
if timezone_name:
|
||||
self._timezone = timezone_name
|
||||
if offset_minutes is not None:
|
||||
self._tz_offset_minutes = int(offset_minutes)
|
||||
if self.owner_kind == "user" and timezone_name:
|
||||
from devplacepy.database import set_user_timezone
|
||||
|
||||
try:
|
||||
set_user_timezone(self.owner_id, timezone_name)
|
||||
except Exception: # noqa: BLE001 - persistence must not break the socket
|
||||
logger.exception(
|
||||
"Failed to persist timezone for %s/%s",
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
)
|
||||
|
||||
def set_timezone(self, timezone_name: str) -> None:
|
||||
if timezone_name:
|
||||
self._timezone = timezone_name
|
||||
|
||||
def set_visibility(self, ws: Any, visible: bool, focused: bool) -> None:
|
||||
meta = self._conn_meta.get(ws)
|
||||
if meta is not None:
|
||||
@@ -322,9 +374,9 @@ class DeviiSession:
|
||||
)
|
||||
await self._emit({"type": "clear"}, buffer=False)
|
||||
|
||||
def spawn_turn(self, text: str) -> None:
|
||||
def spawn_turn(self, content: Any, audit_text: str | None = None) -> None:
|
||||
epoch = self._turn_epoch
|
||||
task = asyncio.create_task(self._run_turn(text, epoch))
|
||||
task = asyncio.create_task(self._run_turn(content, epoch, audit_text))
|
||||
self._turns.add(task)
|
||||
task.add_done_callback(self._turns.discard)
|
||||
|
||||
@@ -373,7 +425,9 @@ class DeviiSession:
|
||||
)
|
||||
await self._emit({"type": "status", "text": "Stopped."}, buffer=False)
|
||||
|
||||
async def _run_turn(self, text: str, epoch: int) -> None:
|
||||
async def _run_turn(
|
||||
self, content: Any, epoch: int, audit_text: str | None = None
|
||||
) -> None:
|
||||
turn_id = uuid_utils.uuid7().hex
|
||||
started_at = _now_iso()
|
||||
before = self._cost_snapshot()
|
||||
@@ -381,13 +435,19 @@ class DeviiSession:
|
||||
reply = ""
|
||||
error = ""
|
||||
cancelled = False
|
||||
prompt_text = audit_text if audit_text is not None else (
|
||||
content if isinstance(content, str) else "[image]"
|
||||
)
|
||||
try:
|
||||
async with self._lock:
|
||||
self._refresh_tools()
|
||||
self._refresh_system_prompt()
|
||||
if self.channel == "docs":
|
||||
await self._docs_topic_gate(text)
|
||||
reply = await self.agent.respond(text)
|
||||
await self._docs_topic_gate(prompt_text)
|
||||
before = self._cost_snapshot()
|
||||
reply = await self.agent.respond(content)
|
||||
if not isinstance(content, str):
|
||||
self._redact_last_user_message(prompt_text)
|
||||
if epoch == self._turn_epoch:
|
||||
await self._emit({"type": "reply", "text": reply}, buffer=True)
|
||||
except asyncio.CancelledError:
|
||||
@@ -399,15 +459,18 @@ class DeviiSession:
|
||||
if epoch == self._turn_epoch:
|
||||
await self._emit({"type": "error", "text": error}, buffer=True)
|
||||
finally:
|
||||
self._record_spend(turn_id, started_at, prompt_text, reply, error, before)
|
||||
if not cancelled and epoch == self._turn_epoch:
|
||||
self._record_turn(turn_id, started_at, text, reply, error, before)
|
||||
self._persist_history()
|
||||
if self.owner_kind == "user" and self.channel != "docs" and not error:
|
||||
from devplacepy.utils import track_action
|
||||
|
||||
track_action(self.owner_id, "devii")
|
||||
|
||||
def _builtin_tools(self) -> list[dict[str, Any]]:
|
||||
schemas = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
|
||||
schemas = CATALOG.tool_schemas_for(
|
||||
self.client.authenticated, self.is_admin, self.is_primary_admin
|
||||
)
|
||||
if self.channel == "docs":
|
||||
return [
|
||||
s
|
||||
@@ -428,7 +491,55 @@ class DeviiSession:
|
||||
return self._system_prompt
|
||||
body = self._behavior_store.text().strip()
|
||||
section = BEHAVIOR_HEADER if not body else f"{BEHAVIOR_HEADER}\n{body}"
|
||||
return f"{self._system_prompt}\n\n{section}"
|
||||
return f"{self._system_prompt}\n\n{self._clock_line()}\n\n{section}"
|
||||
|
||||
def _clock_line(self) -> str:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
tz_name = self._timezone or self._stored_timezone()
|
||||
local = ""
|
||||
if tz_name:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
local_now = now.astimezone(ZoneInfo(tz_name))
|
||||
offset = local_now.utcoffset()
|
||||
offset_text = _format_offset(offset)
|
||||
local = (
|
||||
f" The user's local time is {local_now.strftime('%Y-%m-%d %H:%M:%S')} "
|
||||
f"({tz_name}, UTC{offset_text})."
|
||||
)
|
||||
except Exception: # noqa: BLE001 - unknown tz name: fall back to UTC only
|
||||
local = ""
|
||||
if not local and self._tz_offset_minutes is not None:
|
||||
offset = timedelta(minutes=self._tz_offset_minutes)
|
||||
local = (
|
||||
f" The user's local time is "
|
||||
f"{(now + offset).strftime('%Y-%m-%d %H:%M:%S')} "
|
||||
f"(UTC{_format_offset(offset)})."
|
||||
)
|
||||
return (
|
||||
f"# CURRENT TIME\n"
|
||||
f"The current UTC time is {now.strftime('%Y-%m-%dT%H:%M:%S')}Z.{local} "
|
||||
"When the user gives a wall-clock time (for example '3pm' or 'tomorrow at 09:00'), "
|
||||
"interpret it in the user's local timezone and convert it to UTC for the run_at field. "
|
||||
"For a relative request (for example 'in 40 seconds' or 'in 2 hours'), use delay_seconds "
|
||||
"instead and do not compute an absolute time."
|
||||
)
|
||||
|
||||
def _stored_timezone(self) -> str:
|
||||
if self.owner_kind != "user":
|
||||
return ""
|
||||
try:
|
||||
from devplacepy.database import db
|
||||
|
||||
if "users" not in db.tables:
|
||||
return ""
|
||||
row = db["users"].find_one(uid=self.owner_id)
|
||||
return (row or {}).get("timezone") or ""
|
||||
except Exception: # noqa: BLE001 - clock line is best-effort
|
||||
return ""
|
||||
|
||||
def _refresh_system_prompt(self) -> None:
|
||||
messages = self.agent._messages
|
||||
@@ -506,7 +617,31 @@ class DeviiSession:
|
||||
"cost_usd": self.cost.cost_usd()["total"],
|
||||
}
|
||||
|
||||
def _record_turn(self, turn_id, started_at, prompt, reply, error, before) -> None:
|
||||
def _persist_history(self) -> None:
|
||||
if not self.persist_conversation:
|
||||
return
|
||||
try:
|
||||
self._conv.save(
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
self.agent._messages,
|
||||
self.channel,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - persistence must never break a turn
|
||||
logger.exception(
|
||||
"Failed to persist conversation for %s/%s",
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
)
|
||||
|
||||
def _redact_last_user_message(self, text: str) -> None:
|
||||
messages = self.agent._messages
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "user":
|
||||
message["content"] = text
|
||||
return
|
||||
|
||||
def _record_spend(self, turn_id, started_at, prompt, reply, error, before) -> None:
|
||||
after = self._cost_snapshot()
|
||||
usage = {
|
||||
"prompt_tokens": after["prompt_tokens"] - before["prompt_tokens"],
|
||||
@@ -517,14 +652,13 @@ class DeviiSession:
|
||||
- before["cache_miss_tokens"],
|
||||
}
|
||||
cost_delta = round(after["cost_usd"] - before["cost_usd"], 8)
|
||||
if (
|
||||
cost_delta <= 0
|
||||
and usage["prompt_tokens"] <= 0
|
||||
and usage["completion_tokens"] <= 0
|
||||
):
|
||||
return
|
||||
try:
|
||||
if self.persist_conversation:
|
||||
self._conv.save(
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
self.agent._messages,
|
||||
self.channel,
|
||||
)
|
||||
self._ledger.record(
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
@@ -690,6 +824,31 @@ class DeviiSession:
|
||||
"payload": payload[:RESULT_NOTICE_CHARS],
|
||||
}
|
||||
asyncio.create_task(self._emit(message, buffer=True))
|
||||
if kind in ("done", "finished") and row.get("notify"):
|
||||
self._deliver_reminder(row, payload)
|
||||
|
||||
def _deliver_reminder(self, row: dict[str, Any], payload: str) -> None:
|
||||
if self.owner_kind != "user" or not self.owner_id:
|
||||
return
|
||||
text = (payload or "").strip() or (
|
||||
row.get("label") or "Your scheduled reminder fired."
|
||||
)
|
||||
try:
|
||||
from devplacepy.utils import create_notification
|
||||
|
||||
create_notification(
|
||||
self.owner_id,
|
||||
"reminder",
|
||||
text[:500],
|
||||
row.get("uid", ""),
|
||||
target_url="/devii",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a reminder notification must never crash the scheduler
|
||||
logger.exception(
|
||||
"Failed to deliver reminder notification for %s/%s",
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
)
|
||||
|
||||
|
||||
NON_ADMIN_COST_RULE = (
|
||||
@@ -792,3 +951,12 @@ def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _format_offset(offset: Any) -> str:
|
||||
if offset is None:
|
||||
return "+00:00"
|
||||
total_minutes = int(offset.total_seconds() // 60)
|
||||
sign = "+" if total_minutes >= 0 else "-"
|
||||
total_minutes = abs(total_minutes)
|
||||
return f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}"
|
||||
|
||||
@@ -24,7 +24,7 @@ def _now() -> datetime:
|
||||
|
||||
|
||||
def _iso(moment: datetime) -> str:
|
||||
return moment.isoformat()
|
||||
return moment.isoformat(timespec="microseconds")
|
||||
|
||||
|
||||
class ConversationStore:
|
||||
|
||||
@@ -77,6 +77,13 @@ TASK_ACTIONS: tuple[Action, ...] = (
|
||||
required=True,
|
||||
),
|
||||
field("label", "Optional short human-readable label for the task."),
|
||||
field(
|
||||
"notify",
|
||||
"Set true for a REMINDER: when the task fires, the user is sent an in-app "
|
||||
"notification and live toast carrying the result, so it reaches them even when "
|
||||
"the Devii terminal is closed. Leave false for silent background automation.",
|
||||
kind="boolean",
|
||||
),
|
||||
*SCHEDULE_FIELDS,
|
||||
),
|
||||
),
|
||||
@@ -116,6 +123,11 @@ TASK_ACTIONS: tuple[Action, ...] = (
|
||||
field("prompt", "New prompt."),
|
||||
field("label", "New label."),
|
||||
field("enabled", "Enable or disable the task.", kind="boolean"),
|
||||
field(
|
||||
"notify",
|
||||
"Enable or disable the in-app reminder notification when this task fires.",
|
||||
kind="boolean",
|
||||
),
|
||||
field("kind", "New schedule type when rescheduling."),
|
||||
field("run_at", "New absolute run time for kind=once."),
|
||||
field("delay_seconds", "New relative delay for kind=once.", kind="integer"),
|
||||
|
||||
@@ -51,6 +51,8 @@ def _serialize(row: dict[str, Any], preview: bool) -> dict[str, Any]:
|
||||
"every_seconds": row.get("every_seconds"),
|
||||
"cron": row.get("cron"),
|
||||
"run_at": row.get("run_at"),
|
||||
"notify": bool(row.get("notify")),
|
||||
"tz": row.get("tz") or None,
|
||||
}
|
||||
last_error = row.get("last_error")
|
||||
if last_error:
|
||||
@@ -98,6 +100,8 @@ class TaskController:
|
||||
"run_count": 0,
|
||||
"last_result": None,
|
||||
"last_error": None,
|
||||
"notify": 1 if _as_bool(arguments.get("notify"), default=False) else 0,
|
||||
"tz": (arguments.get("tz") or "").strip() or None,
|
||||
**schedule.columns(),
|
||||
}
|
||||
self._store.create(record)
|
||||
@@ -137,6 +141,10 @@ class TaskController:
|
||||
changes["label"] = (arguments.get("label") or "").strip() or None
|
||||
if "enabled" in arguments:
|
||||
changes["enabled"] = _as_bool(arguments.get("enabled"))
|
||||
if "notify" in arguments and arguments["notify"] is not None:
|
||||
changes["notify"] = 1 if _as_bool(arguments.get("notify")) else 0
|
||||
if "tz" in arguments and arguments["tz"] is not None:
|
||||
changes["tz"] = (str(arguments.get("tz")) or "").strip() or None
|
||||
|
||||
if any(
|
||||
key in arguments and arguments[key] is not None for key in SCHEDULE_KEYS
|
||||
|
||||
@@ -24,6 +24,24 @@ def memory_db() -> Any:
|
||||
return dataset.connect("sqlite:///:memory:")
|
||||
|
||||
|
||||
ACTIVE_STATUSES = ("pending", "running")
|
||||
|
||||
|
||||
def pending_owner_ids(db: Any) -> list[str]:
|
||||
if TABLE not in db.tables:
|
||||
return []
|
||||
table = db[TABLE]
|
||||
if "owner_id" not in table.columns:
|
||||
return []
|
||||
rows = table.find(owner_kind="user", enabled=True, deleted_at=None)
|
||||
owners = {
|
||||
row["owner_id"]
|
||||
for row in rows
|
||||
if row.get("owner_id") and row.get("status") in ACTIVE_STATUSES
|
||||
}
|
||||
return sorted(owners)
|
||||
|
||||
|
||||
class TaskStore:
|
||||
def __init__(self, db: Any, owner_kind: str, owner_id: str) -> None:
|
||||
self._db = db
|
||||
@@ -39,6 +57,10 @@ class TaskStore:
|
||||
table.create_column_by_example("deleted_at", "")
|
||||
if not table.has_column("deleted_by"):
|
||||
table.create_column_by_example("deleted_by", "")
|
||||
if not table.has_column("notify"):
|
||||
table.create_column_by_example("notify", 0)
|
||||
if not table.has_column("tz"):
|
||||
table.create_column_by_example("tz", "")
|
||||
for columns in INDEXED_COLUMNS:
|
||||
table.create_index(columns)
|
||||
|
||||
@@ -109,6 +131,10 @@ class TaskStore:
|
||||
logger.info("Recovered %d task(s) stuck in running", len(stuck))
|
||||
return len(stuck)
|
||||
|
||||
def has_pending(self) -> bool:
|
||||
rows = self._table.find(enabled=True, deleted_at=None, **self._scope)
|
||||
return any(row.get("status") in ACTIVE_STATUSES for row in rows)
|
||||
|
||||
def due(self, now_iso: str) -> list[dict[str, Any]]:
|
||||
rows = self._table.find(
|
||||
enabled=True, status="pending", deleted_at=None, **self._scope
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .controller import TelegramSendController
|
||||
|
||||
__all__ = ["TelegramSendController"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
|
||||
|
||||
class TelegramSendController:
|
||||
def __init__(self, owner_kind: str, owner_id: str) -> None:
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name == "telegram_send":
|
||||
return await self._send(arguments)
|
||||
raise ToolInputError(f"Unknown telegram tool: {name}")
|
||||
|
||||
async def _send(self, arguments: dict[str, Any]) -> str:
|
||||
if self._owner_kind != "user":
|
||||
raise ToolInputError(
|
||||
"Telegram delivery is only available for signed-in users."
|
||||
)
|
||||
text = str(arguments.get("text", "")).strip()
|
||||
if not text:
|
||||
raise ToolInputError("'text' is required and cannot be empty.")
|
||||
from devplacepy.services.telegram import store
|
||||
|
||||
link = store.link_for_user(self._owner_id)
|
||||
if not link:
|
||||
raise ToolInputError(
|
||||
"Your Telegram account is not paired. Ask the user to request a pairing "
|
||||
"code from their profile and connect Telegram first."
|
||||
)
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
service = service_manager.get_service("telegram")
|
||||
if (
|
||||
service is None
|
||||
or not service.is_enabled()
|
||||
or not service_manager.owns_lock()
|
||||
or not service.worker_alive()
|
||||
):
|
||||
raise ToolInputError("The Telegram bot service is not running right now.")
|
||||
delivered = await service.send_markdown(int(link["chat_id"]), text)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success" if delivered else "failed",
|
||||
"delivered": delivered,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import EmailClient
|
||||
from .config import EmailAccount
|
||||
from .errors import EmailError
|
||||
|
||||
__all__ = ["EmailClient", "EmailAccount", "EmailError"]
|
||||
@@ -0,0 +1,355 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import email
|
||||
import imaplib
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from contextlib import contextmanager
|
||||
from email.message import EmailMessage
|
||||
from email.policy import default as default_policy
|
||||
from email.utils import formataddr, getaddresses, parsedate_to_datetime
|
||||
from typing import Any, Iterator
|
||||
|
||||
from devplacepy.net_guard import BlockedAddressError, guard_public_host_sync
|
||||
|
||||
from .config import EmailAccount
|
||||
from .errors import EmailError
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
MAX_BODY_CHARS = 80_000
|
||||
HEADER_FIELDS = "(FROM TO CC SUBJECT DATE MESSAGE-ID)"
|
||||
SUMMARY_ITEMS = f"(UID FLAGS BODY.PEEK[HEADER.FIELDS {HEADER_FIELDS}])"
|
||||
_UID_PATTERN = re.compile(rb"UID (\d+)")
|
||||
|
||||
|
||||
def imap_quote(value: str) -> str:
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def _header_addresses(value: str) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [formataddr(pair) for pair in getaddresses([value]) if pair[1]]
|
||||
|
||||
|
||||
def _decode_date(raw: str) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return parsedate_to_datetime(raw).isoformat()
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
|
||||
|
||||
class EmailClient:
|
||||
def __init__(
|
||||
self, account: EmailAccount, timeout: float = DEFAULT_TIMEOUT_SECONDS
|
||||
) -> None:
|
||||
self.account = account
|
||||
self.timeout = timeout
|
||||
|
||||
@contextmanager
|
||||
def _imap(self) -> Iterator[imaplib.IMAP4]:
|
||||
account = self.account
|
||||
if not account.imap_host:
|
||||
raise EmailError("This account has no IMAP host configured.", kind="config")
|
||||
try:
|
||||
guard_public_host_sync(account.imap_host)
|
||||
except BlockedAddressError as exc:
|
||||
raise EmailError(str(exc), kind="blocked") from exc
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
if account.imap_ssl:
|
||||
conn: imaplib.IMAP4 = imaplib.IMAP4_SSL(
|
||||
account.imap_host,
|
||||
account.imap_port,
|
||||
ssl_context=context,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
else:
|
||||
conn = imaplib.IMAP4(
|
||||
account.imap_host, account.imap_port, timeout=self.timeout
|
||||
)
|
||||
if account.imap_starttls:
|
||||
conn.starttls(context)
|
||||
conn.login(account.username, account.password)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise EmailError(f"IMAP connection failed: {exc}", kind="connect") from exc
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except (OSError, imaplib.IMAP4.error):
|
||||
pass
|
||||
|
||||
def _select(self, conn: imaplib.IMAP4, folder: str, readonly: bool) -> None:
|
||||
typ, data = conn.select(imap_quote(folder), readonly=readonly)
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Cannot open folder '{folder}': {_first(data)}")
|
||||
|
||||
def _uid_search(self, conn: imaplib.IMAP4, criteria: list[str]) -> list[str]:
|
||||
if not criteria:
|
||||
typ, data = conn.uid("SEARCH", None, "ALL")
|
||||
else:
|
||||
args: list[Any] = [b"CHARSET", b"UTF-8"]
|
||||
args.extend(token.encode("utf-8") for token in criteria)
|
||||
typ, data = conn.uid("SEARCH", *args)
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Search failed: {_first(data)}")
|
||||
if not data or not data[0]:
|
||||
return []
|
||||
return [chunk.decode() for chunk in data[0].split()]
|
||||
|
||||
def list_folders(self) -> list[dict[str, Any]]:
|
||||
with self._imap() as conn:
|
||||
typ, data = conn.list()
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Cannot list folders: {_first(data)}")
|
||||
folders: list[dict[str, Any]] = []
|
||||
for entry in data or []:
|
||||
if entry is None:
|
||||
continue
|
||||
line = entry.decode("utf-8", "replace") if isinstance(entry, bytes) else str(entry)
|
||||
match = re.match(r"\((?P<flags>[^)]*)\) \"?(?P<delim>[^\"]*)\"? (?P<name>.+)", line)
|
||||
if not match:
|
||||
continue
|
||||
name = match.group("name").strip().strip('"')
|
||||
folders.append({"name": name, "flags": match.group("flags").split()})
|
||||
return folders
|
||||
|
||||
def list_messages(
|
||||
self,
|
||||
folder: str,
|
||||
criteria: list[str],
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> dict[str, Any]:
|
||||
with self._imap() as conn:
|
||||
self._select(conn, folder, readonly=True)
|
||||
uids = self._uid_search(conn, criteria)
|
||||
uids.reverse()
|
||||
total = len(uids)
|
||||
window = uids[offset : offset + limit]
|
||||
messages = self._fetch_summaries(conn, window)
|
||||
return {
|
||||
"folder": folder,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"count": len(messages),
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
def _fetch_summaries(
|
||||
self, conn: imaplib.IMAP4, uids: list[str]
|
||||
) -> list[dict[str, Any]]:
|
||||
if not uids:
|
||||
return []
|
||||
typ, data = conn.uid("FETCH", ",".join(uids), SUMMARY_ITEMS)
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Fetch failed: {_first(data)}")
|
||||
order = {uid: index for index, uid in enumerate(uids)}
|
||||
summaries: list[dict[str, Any]] = []
|
||||
for part in data or []:
|
||||
if not isinstance(part, tuple) or len(part) < 2:
|
||||
continue
|
||||
descriptor, header_bytes = part[0], part[1]
|
||||
uid_match = _UID_PATTERN.search(descriptor or b"")
|
||||
if not uid_match:
|
||||
continue
|
||||
uid = uid_match.group(1).decode()
|
||||
flags = [flag.decode() if isinstance(flag, bytes) else str(flag) for flag in imaplib.ParseFlags(descriptor)]
|
||||
headers = email.message_from_bytes(header_bytes or b"", policy=default_policy)
|
||||
summaries.append(
|
||||
{
|
||||
"uid": uid,
|
||||
"flags": flags,
|
||||
"seen": "\\Seen" in flags,
|
||||
"from": str(headers.get("From", "")),
|
||||
"to": _header_addresses(str(headers.get("To", ""))),
|
||||
"subject": str(headers.get("Subject", "")),
|
||||
"date": _decode_date(str(headers.get("Date", ""))),
|
||||
"message_id": str(headers.get("Message-ID", "")),
|
||||
}
|
||||
)
|
||||
summaries.sort(key=lambda item: order.get(item["uid"], 0))
|
||||
return summaries
|
||||
|
||||
def read_message(self, folder: str, uid: str) -> dict[str, Any]:
|
||||
with self._imap() as conn:
|
||||
self._select(conn, folder, readonly=True)
|
||||
typ, data = conn.uid("FETCH", uid, "(FLAGS BODY.PEEK[])")
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Fetch failed: {_first(data)}")
|
||||
raw = _first_payload(data)
|
||||
if raw is None:
|
||||
raise EmailError(f"Message {uid} not found in '{folder}'.", kind="not_found")
|
||||
flags = [flag.decode() if isinstance(flag, bytes) else str(flag) for flag in imaplib.ParseFlags(_first_descriptor(data) or b"")]
|
||||
message = email.message_from_bytes(raw, policy=default_policy)
|
||||
return self._render_message(uid, folder, flags, message)
|
||||
|
||||
def _render_message(
|
||||
self, uid: str, folder: str, flags: list[str], message: Any
|
||||
) -> dict[str, Any]:
|
||||
text_part = message.get_body(preferencelist=("plain",))
|
||||
html_part = message.get_body(preferencelist=("html",))
|
||||
attachments: list[dict[str, Any]] = []
|
||||
for part in message.iter_attachments():
|
||||
payload = part.get_content()
|
||||
size = len(payload) if isinstance(payload, (bytes, str)) else 0
|
||||
attachments.append(
|
||||
{
|
||||
"filename": part.get_filename() or "",
|
||||
"content_type": part.get_content_type(),
|
||||
"size": size,
|
||||
}
|
||||
)
|
||||
body = text_part.get_content().strip() if text_part else ""
|
||||
html = html_part.get_content().strip() if html_part else ""
|
||||
return {
|
||||
"uid": uid,
|
||||
"folder": folder,
|
||||
"flags": flags,
|
||||
"from": str(message.get("From", "")),
|
||||
"to": _header_addresses(str(message.get("To", ""))),
|
||||
"cc": _header_addresses(str(message.get("Cc", ""))),
|
||||
"subject": str(message.get("Subject", "")),
|
||||
"date": _decode_date(str(message.get("Date", ""))),
|
||||
"message_id": str(message.get("Message-ID", "")),
|
||||
"body": body[:MAX_BODY_CHARS],
|
||||
"body_truncated": len(body) > MAX_BODY_CHARS,
|
||||
"html": html[:MAX_BODY_CHARS],
|
||||
"attachments": attachments,
|
||||
}
|
||||
|
||||
def search(self, folder: str, criteria: list[str], limit: int) -> dict[str, Any]:
|
||||
return self.list_messages(folder, criteria, limit, 0)
|
||||
|
||||
def set_flags(
|
||||
self, folder: str, uid: str, flags: list[str], add: bool
|
||||
) -> dict[str, Any]:
|
||||
with self._imap() as conn:
|
||||
self._select(conn, folder, readonly=False)
|
||||
command = "+FLAGS" if add else "-FLAGS"
|
||||
typ, data = conn.uid("STORE", uid, command, "(%s)" % " ".join(flags))
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Flag update failed: {_first(data)}")
|
||||
return {"uid": uid, "folder": folder, "flags": flags, "added": add}
|
||||
|
||||
def move_message(self, folder: str, uid: str, destination: str) -> dict[str, Any]:
|
||||
with self._imap() as conn:
|
||||
self._select(conn, folder, readonly=False)
|
||||
typ, data = conn.uid("COPY", uid, imap_quote(destination))
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Copy to '{destination}' failed: {_first(data)}")
|
||||
conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
|
||||
conn.expunge()
|
||||
return {"uid": uid, "from_folder": folder, "to_folder": destination}
|
||||
|
||||
def delete_message(
|
||||
self, folder: str, uid: str, trash: str | None
|
||||
) -> dict[str, Any]:
|
||||
if trash and trash != folder:
|
||||
result = self.move_message(folder, uid, trash)
|
||||
result["deleted"] = True
|
||||
return result
|
||||
with self._imap() as conn:
|
||||
self._select(conn, folder, readonly=False)
|
||||
typ, data = conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
|
||||
if typ != "OK":
|
||||
raise EmailError(f"Delete failed: {_first(data)}")
|
||||
conn.expunge()
|
||||
return {"uid": uid, "folder": folder, "deleted": True}
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
to: list[str],
|
||||
subject: str,
|
||||
body: str,
|
||||
cc: list[str] | None = None,
|
||||
bcc: list[str] | None = None,
|
||||
html: str | None = None,
|
||||
in_reply_to: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
account = self.account
|
||||
if not account.smtp_host:
|
||||
raise EmailError("This account has no SMTP host configured.", kind="config")
|
||||
message = EmailMessage()
|
||||
message["From"] = (
|
||||
formataddr((account.from_name, account.from_address))
|
||||
if account.from_name
|
||||
else account.from_address
|
||||
)
|
||||
message["To"] = ", ".join(to)
|
||||
if cc:
|
||||
message["Cc"] = ", ".join(cc)
|
||||
message["Subject"] = subject
|
||||
if in_reply_to:
|
||||
message["In-Reply-To"] = in_reply_to
|
||||
message["References"] = in_reply_to
|
||||
message.set_content(body)
|
||||
if html:
|
||||
message.add_alternative(html, subtype="html")
|
||||
recipients = list(to) + list(cc or []) + list(bcc or [])
|
||||
try:
|
||||
guard_public_host_sync(account.smtp_host)
|
||||
except BlockedAddressError as exc:
|
||||
raise EmailError(str(exc), kind="blocked") from exc
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
if account.smtp_ssl:
|
||||
server: smtplib.SMTP = smtplib.SMTP_SSL(
|
||||
account.smtp_host,
|
||||
account.smtp_port,
|
||||
timeout=self.timeout,
|
||||
context=context,
|
||||
)
|
||||
else:
|
||||
server = smtplib.SMTP(
|
||||
account.smtp_host, account.smtp_port, timeout=self.timeout
|
||||
)
|
||||
if account.smtp_starttls:
|
||||
server.starttls(context=context)
|
||||
with server:
|
||||
if account.username and account.password:
|
||||
server.login(account.username, account.password)
|
||||
server.send_message(
|
||||
message, from_addr=account.from_address, to_addrs=recipients
|
||||
)
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
raise EmailError(f"Sending failed: {exc}", kind="send") from exc
|
||||
return {
|
||||
"sent": True,
|
||||
"from": account.from_address,
|
||||
"to": to,
|
||||
"cc": cc or [],
|
||||
"bcc": bcc or [],
|
||||
"subject": subject,
|
||||
}
|
||||
|
||||
|
||||
def _first(data: Any) -> str:
|
||||
if not data:
|
||||
return ""
|
||||
head = data[0]
|
||||
if isinstance(head, bytes):
|
||||
return head.decode("utf-8", "replace")
|
||||
return str(head)
|
||||
|
||||
|
||||
def _first_descriptor(data: Any) -> bytes | None:
|
||||
for part in data or []:
|
||||
if isinstance(part, tuple) and part:
|
||||
return part[0]
|
||||
return None
|
||||
|
||||
|
||||
def _first_payload(data: Any) -> bytes | None:
|
||||
for part in data or []:
|
||||
if isinstance(part, tuple) and len(part) >= 2:
|
||||
return part[1]
|
||||
return None
|
||||
@@ -0,0 +1,55 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _as_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _as_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailAccount:
|
||||
label: str
|
||||
imap_host: str
|
||||
imap_port: int
|
||||
imap_ssl: bool
|
||||
imap_starttls: bool
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_ssl: bool
|
||||
smtp_starttls: bool
|
||||
username: str
|
||||
password: str
|
||||
from_address: str
|
||||
from_name: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: dict[str, Any]) -> "EmailAccount":
|
||||
username = str(row.get("username") or "")
|
||||
return cls(
|
||||
label=str(row.get("label") or ""),
|
||||
imap_host=str(row.get("imap_host") or ""),
|
||||
imap_port=_as_int(row.get("imap_port"), 993),
|
||||
imap_ssl=_as_bool(row.get("imap_ssl")),
|
||||
imap_starttls=_as_bool(row.get("imap_starttls")),
|
||||
smtp_host=str(row.get("smtp_host") or ""),
|
||||
smtp_port=_as_int(row.get("smtp_port"), 587),
|
||||
smtp_ssl=_as_bool(row.get("smtp_ssl")),
|
||||
smtp_starttls=_as_bool(row.get("smtp_starttls")),
|
||||
username=username,
|
||||
password=str(row.get("password") or ""),
|
||||
from_address=str(row.get("from_address") or username),
|
||||
from_name=str(row.get("from_name") or ""),
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class EmailError(Exception):
|
||||
def __init__(self, message: str, *, kind: str = "error") -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.kind = kind
|
||||
@@ -99,6 +99,12 @@ async def _ai_usage(match: re.Match) -> dict:
|
||||
return build_analytics(hours, top_n=10, pricing=pricing)
|
||||
|
||||
|
||||
async def _backups(_match: re.Match) -> dict:
|
||||
from devplacepy.routers.admin.backups import _dashboard
|
||||
|
||||
return _dashboard(can_download=False)
|
||||
|
||||
|
||||
VIEWS = [
|
||||
(re.compile(r"^container\.list$"), _container_list, 4.0),
|
||||
(re.compile(rf"^project\.(?P<slug>{_SEGMENT})\.containers$"), _project_containers, 3.0),
|
||||
@@ -108,6 +114,7 @@ VIEWS = [
|
||||
(re.compile(r"^admin\.services$"), _services, 5.0),
|
||||
(re.compile(rf"^admin\.services\.(?P<name>{_SEGMENT})$"), _service_detail, 5.0),
|
||||
(re.compile(r"^admin\.ai-usage\.(?P<hours>\d+)$"), _ai_usage, 15.0),
|
||||
(re.compile(r"^admin\.backups$"), _backups, 8.0),
|
||||
]
|
||||
|
||||
|
||||
@@ -115,7 +122,7 @@ class LiveViewRelayService(BaseService):
|
||||
title = "Live view relay"
|
||||
description = (
|
||||
"Pushes admin live-view snapshots (container list and instances, bot fleet, "
|
||||
"background services, AI usage) onto the pub/sub bus, computed only for topics "
|
||||
"background services, AI usage, backups) onto the pub/sub bus, computed only for topics "
|
||||
"that currently have subscribers. Runs on the service lock owner where pub/sub "
|
||||
"subscribers converge, replacing per-client HTTP polling with server push."
|
||||
)
|
||||
|
||||
@@ -25,7 +25,21 @@ def _now() -> datetime:
|
||||
|
||||
|
||||
def _iso(moment: datetime) -> str:
|
||||
return moment.isoformat()
|
||||
return moment.isoformat(timespec="microseconds")
|
||||
|
||||
|
||||
def _hours_since(iso_value: Optional[str], now: datetime) -> float:
|
||||
try:
|
||||
moment = datetime.fromisoformat(iso_value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return max(0.0, (now - moment).total_seconds() / 3600.0)
|
||||
|
||||
|
||||
def _project_monthly(cost: float, observed_hours: float) -> float:
|
||||
if observed_hours >= 1.0:
|
||||
return cost / observed_hours * 24 * 30
|
||||
return cost * 30
|
||||
|
||||
|
||||
def _pset(values: list[float]) -> dict:
|
||||
@@ -163,6 +177,11 @@ def build_analytics(
|
||||
first_hour[owner] = bucket
|
||||
|
||||
total_cost = sum(float(r.get("cost_usd") or 0) for r in rows)
|
||||
token_cost = sum(
|
||||
float(r.get("cost_usd") or 0)
|
||||
for r in rows
|
||||
if int(r.get("total_tokens") or 0) > 0
|
||||
)
|
||||
input_cost = sum(float(r.get("input_cost_usd") or 0) for r in rows)
|
||||
output_cost = sum(float(r.get("output_cost_usd") or 0) for r in rows)
|
||||
cost_this_hour = sum(
|
||||
@@ -170,8 +189,12 @@ def build_analytics(
|
||||
for r in rows
|
||||
if r["created_at"][:13] == hour_start
|
||||
)
|
||||
cost_24h = sum(
|
||||
float(r.get("cost_usd") or 0) for r in rows if r["created_at"] >= day_cutoff
|
||||
last_24h_rows = [r for r in rows if r["created_at"] >= day_cutoff]
|
||||
cost_24h = sum(float(r.get("cost_usd") or 0) for r in last_24h_rows)
|
||||
observed_24h_hours = (
|
||||
min(24.0, _hours_since(last_24h_rows[0]["created_at"], now))
|
||||
if last_24h_rows
|
||||
else 0.0
|
||||
)
|
||||
|
||||
prompt_total = sum(int(r.get("prompt_tokens") or 0) for r in rows)
|
||||
@@ -187,7 +210,7 @@ def build_analytics(
|
||||
chat_cache_hits = sum(
|
||||
int(r.get("cache_hit_tokens") or 0)
|
||||
for r in rows
|
||||
if r.get("backend") == "chat"
|
||||
if r.get("backend") == "chat" and not int(r.get("native_cost") or 0)
|
||||
)
|
||||
caching_savings = chat_cache_hits / PER_MILLION * rate_delta
|
||||
|
||||
@@ -308,8 +331,8 @@ def build_analytics(
|
||||
"output_usd": round(output_cost, 6),
|
||||
"this_hour_usd": round(cost_this_hour, 6),
|
||||
"last_24h_usd": round(cost_24h, 6),
|
||||
"projected_monthly_usd": round(cost_24h * 30, 2),
|
||||
"effective_per_1k_tokens_usd": round(total_cost / total_tokens * 1000, 6)
|
||||
"projected_monthly_usd": round(_project_monthly(cost_24h, observed_24h_hours), 2),
|
||||
"effective_per_1k_tokens_usd": round(token_cost / total_tokens * 1000, 6)
|
||||
if total_tokens
|
||||
else 0.0,
|
||||
"caching_savings_usd": round(caching_savings, 6),
|
||||
@@ -509,7 +532,9 @@ def build_user_usage(
|
||||
avg_latency = round(sum(latencies) / len(latencies), 1) if latencies else 0.0
|
||||
tps = _positive(rows, "tokens_per_second")
|
||||
avg_tps = round(sum(tps) / len(tps), 2) if tps else 0.0
|
||||
cost_per_hour = total_cost / hours
|
||||
observed_hours = min(float(hours), _hours_since(rows[0]["created_at"], now))
|
||||
rate_hours = observed_hours if observed_hours >= 1.0 else float(hours)
|
||||
cost_per_hour = total_cost / rate_hours
|
||||
cost_per_request = total_cost / requests if requests else 0.0
|
||||
|
||||
return {
|
||||
@@ -539,7 +564,9 @@ def build_user_usage(
|
||||
"by_backend": _top_group(rows, lambda r: r.get("backend") or "unknown", 0),
|
||||
"hourly": _user_hourly(rows),
|
||||
"notes": {
|
||||
"projection": "30-day projection extrapolates the full 24h spend (24h cost x 30)"
|
||||
"projection": "30-day projection from the observed hourly burn rate "
|
||||
"(per-hour spend x 24 x 30); windows with under an hour of activity fall "
|
||||
"back to a conservative window-average to avoid over-extrapolation"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ class GatewayRuntime:
|
||||
handle_start = time.monotonic()
|
||||
messages = body.get("messages", []) or []
|
||||
|
||||
vision_cost = 0.0
|
||||
if cfg["gateway_vision_enabled"]:
|
||||
augmenter = VisionAugmenter(
|
||||
cfg["gateway_vision_url"],
|
||||
@@ -238,6 +239,7 @@ class GatewayRuntime:
|
||||
)
|
||||
messages = await augmenter.augment_messages(client, messages)
|
||||
self.vision_calls += augmenter.calls
|
||||
vision_cost = augmenter.cost_usd
|
||||
|
||||
messages = apply_system_directives(messages, cfg.get("gateway_system_preamble", ""))
|
||||
|
||||
@@ -300,9 +302,13 @@ class GatewayRuntime:
|
||||
base["success"] = success
|
||||
base["error_category"] = category
|
||||
base["usage"] = usage
|
||||
return usage_response_headers(
|
||||
self._ledger.record(base, pricing, context_map)
|
||||
)
|
||||
row = self._ledger.record(base, pricing, context_map)
|
||||
headers = usage_response_headers(row)
|
||||
if vision_cost and headers:
|
||||
chat_cost = float((row or {}).get("cost_usd") or 0.0)
|
||||
headers["X-Gateway-Cost-USD"] = f"{chat_cost + vision_cost:.8f}"
|
||||
headers["X-Gateway-Vision-Cost-USD"] = f"{vision_cost:.8f}"
|
||||
return headers
|
||||
|
||||
if timing["circuit_open"]:
|
||||
resp_headers = finalize(503, False, "circuit_open")
|
||||
@@ -370,6 +376,7 @@ class GatewayRuntime:
|
||||
self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None
|
||||
):
|
||||
log = log or (lambda message: None)
|
||||
vision_cost = 0.0
|
||||
overlay = embed_overlay(body.get("model"), cfg)
|
||||
if overlay:
|
||||
cfg = {**cfg, **overlay}
|
||||
@@ -465,9 +472,13 @@ class GatewayRuntime:
|
||||
base["success"] = success
|
||||
base["error_category"] = category
|
||||
base["usage"] = usage
|
||||
return usage_response_headers(
|
||||
self._ledger.record(base, pricing, context_map)
|
||||
)
|
||||
row = self._ledger.record(base, pricing, context_map)
|
||||
headers = usage_response_headers(row)
|
||||
if vision_cost and headers:
|
||||
chat_cost = float((row or {}).get("cost_usd") or 0.0)
|
||||
headers["X-Gateway-Cost-USD"] = f"{chat_cost + vision_cost:.8f}"
|
||||
headers["X-Gateway-Vision-Cost-USD"] = f"{vision_cost:.8f}"
|
||||
return headers
|
||||
|
||||
if timing["circuit_open"]:
|
||||
resp_headers = finalize(503, False, "circuit_open")
|
||||
@@ -538,6 +549,7 @@ class GatewayRuntime:
|
||||
log=None,
|
||||
):
|
||||
log = log or (lambda message: None)
|
||||
vision_cost = 0.0
|
||||
client, sem = self._ensure(cfg)
|
||||
pricing = pricing_from_cfg(cfg)
|
||||
context_map = parse_context_map(cfg.get("gateway_model_context_map"))
|
||||
@@ -583,9 +595,13 @@ class GatewayRuntime:
|
||||
base["success"] = success
|
||||
base["error_category"] = category
|
||||
base["usage"] = usage
|
||||
return usage_response_headers(
|
||||
self._ledger.record(base, pricing, context_map)
|
||||
)
|
||||
row = self._ledger.record(base, pricing, context_map)
|
||||
headers = usage_response_headers(row)
|
||||
if vision_cost and headers:
|
||||
chat_cost = float((row or {}).get("cost_usd") or 0.0)
|
||||
headers["X-Gateway-Cost-USD"] = f"{chat_cost + vision_cost:.8f}"
|
||||
headers["X-Gateway-Vision-Cost-USD"] = f"{vision_cost:.8f}"
|
||||
return headers
|
||||
|
||||
if timing["circuit_open"]:
|
||||
resp_headers = finalize(503, False, "circuit_open")
|
||||
|
||||
@@ -354,6 +354,7 @@ def chat_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[dic
|
||||
overlay["gateway_vision_enabled"] = True
|
||||
overlay["gateway_vision_model"] = route.vision_model
|
||||
overlay["gateway_vision_price_input_per_m"] = route.price_input_per_m
|
||||
overlay["gateway_vision_price_output_per_m"] = route.price_output_per_m
|
||||
vision_provider = route.vision_provider or route.provider
|
||||
if vision_provider:
|
||||
_provider_overlay(
|
||||
|
||||
@@ -25,7 +25,7 @@ def _now() -> datetime:
|
||||
|
||||
|
||||
def _iso(moment: datetime) -> str:
|
||||
return moment.isoformat()
|
||||
return moment.isoformat(timespec="microseconds")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -120,24 +120,30 @@ def normalize_usage(usage: Optional[dict]) -> dict:
|
||||
def compute_cost(
|
||||
usage: dict, norm: dict, pricing: Pricing, backend: str
|
||||
) -> tuple[float, float, float, bool]:
|
||||
native = usage.get("cost") if isinstance(usage, dict) else None
|
||||
if isinstance(native, (int, float)) and not isinstance(native, bool):
|
||||
total = float(native)
|
||||
denom = norm["prompt"] + norm["completion"]
|
||||
input_cost = total * norm["prompt"] / denom if denom > 0 else 0.0
|
||||
return total, input_cost, total - input_cost, True
|
||||
if backend == "vision":
|
||||
input_cost = norm["prompt"] / PER_MILLION * pricing.vision_input_per_m
|
||||
output_cost = norm["completion"] / PER_MILLION * pricing.vision_output_per_m
|
||||
return input_cost + output_cost, input_cost, output_cost, False
|
||||
if backend == "embed":
|
||||
elif backend == "embed":
|
||||
input_cost = norm["prompt"] / PER_MILLION * pricing.embed_input_per_m
|
||||
return input_cost, input_cost, 0.0, False
|
||||
input_cost = (
|
||||
norm["cache_hit"] / PER_MILLION * pricing.chat_cache_hit_per_m
|
||||
+ norm["cache_miss"] / PER_MILLION * pricing.chat_cache_miss_per_m
|
||||
)
|
||||
output_cost = norm["completion"] / PER_MILLION * pricing.chat_output_per_m
|
||||
output_cost = 0.0
|
||||
else:
|
||||
input_cost = (
|
||||
norm["cache_hit"] / PER_MILLION * pricing.chat_cache_hit_per_m
|
||||
+ norm["cache_miss"] / PER_MILLION * pricing.chat_cache_miss_per_m
|
||||
)
|
||||
output_cost = norm["completion"] / PER_MILLION * pricing.chat_output_per_m
|
||||
native = usage.get("cost") if isinstance(usage, dict) else None
|
||||
if isinstance(native, (int, float)) and not isinstance(native, bool):
|
||||
total = max(0.0, float(native))
|
||||
modeled = input_cost + output_cost
|
||||
if modeled > 0:
|
||||
input_share = input_cost / modeled
|
||||
elif norm["prompt"] + norm["completion"] > 0:
|
||||
input_share = norm["prompt"] / (norm["prompt"] + norm["completion"])
|
||||
else:
|
||||
input_share = 0.0
|
||||
native_input = total * input_share
|
||||
return total, native_input, total - native_input, True
|
||||
return input_cost + output_cost, input_cost, output_cost, False
|
||||
|
||||
|
||||
@@ -249,6 +255,35 @@ def usage_response_headers(row: Optional[dict]) -> dict:
|
||||
return headers
|
||||
|
||||
|
||||
def parse_usage_headers(headers) -> Optional[dict]:
|
||||
if not headers or "X-Gateway-Cost-USD" not in headers:
|
||||
return None
|
||||
|
||||
def _int(name: str) -> int:
|
||||
try:
|
||||
return int(headers.get(name) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def _float(name: str) -> float:
|
||||
try:
|
||||
return float(headers.get(name) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
return {
|
||||
"calls": 1,
|
||||
"model": headers.get("X-Gateway-Model") or "",
|
||||
"prompt_tokens": _int("X-Gateway-Prompt-Tokens"),
|
||||
"completion_tokens": _int("X-Gateway-Completion-Tokens"),
|
||||
"total_tokens": _int("X-Gateway-Total-Tokens"),
|
||||
"cost_usd": _float("X-Gateway-Cost-USD"),
|
||||
"native_cost": headers.get("X-Gateway-Cost-Native") == "1",
|
||||
"upstream_latency_ms": _float("X-Gateway-Upstream-Latency-Ms"),
|
||||
"total_latency_ms": _float("X-Gateway-Total-Latency-Ms"),
|
||||
}
|
||||
|
||||
|
||||
class GatewayUsageLedger:
|
||||
def record(self, raw: dict, pricing: Pricing, context_map: dict) -> Optional[dict]:
|
||||
try:
|
||||
|
||||
@@ -106,11 +106,12 @@ class VisionAugmenter:
|
||||
self.pricing = pricing
|
||||
self.context_map = context_map or {}
|
||||
self.calls = 0
|
||||
self.cost_usd = 0.0
|
||||
|
||||
def _record(self, latency_ms, status_code, success, category, usage):
|
||||
if self.ledger is None or self.pricing is None:
|
||||
return
|
||||
self.ledger.record(
|
||||
row = self.ledger.record(
|
||||
{
|
||||
"owner_kind": self.owner[0],
|
||||
"owner_id": self.owner[1],
|
||||
@@ -129,6 +130,8 @@ class VisionAugmenter:
|
||||
self.pricing,
|
||||
self.context_map,
|
||||
)
|
||||
if row:
|
||||
self.cost_usd += float(row.get("cost_usd") or 0.0)
|
||||
|
||||
async def _describe_one(self, client: httpx.AsyncClient, image_block: dict) -> str:
|
||||
if not self.vision_key:
|
||||
|
||||
@@ -5,9 +5,12 @@ from __future__ import annotations
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.services.dbapi.policy import caller_for as _dbapi_caller
|
||||
from devplacepy.utils import get_current_user, is_admin
|
||||
from devplacepy.database import get_setting, internal_gateway_key
|
||||
from devplacepy.utils import (
|
||||
_user_from_api_key,
|
||||
get_current_user,
|
||||
is_admin,
|
||||
)
|
||||
|
||||
TOPIC_NAME = re.compile(r"^[A-Za-z0-9_.*-]{1,128}$")
|
||||
MAX_PAYLOAD_BYTES = 64 * 1024
|
||||
@@ -28,14 +31,31 @@ def guests_enabled() -> bool:
|
||||
return get_setting("pubsub_allow_guests", "0") == "1"
|
||||
|
||||
|
||||
def _bearer_key(scope) -> str:
|
||||
key = scope.headers.get("x-api-key", "").strip()
|
||||
if key:
|
||||
return key
|
||||
scheme, _, credentials = scope.headers.get("authorization", "").partition(" ")
|
||||
if scheme.lower() == "bearer":
|
||||
return credentials.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_actor(scope) -> Actor:
|
||||
caller = _dbapi_caller(scope)
|
||||
if caller is not None:
|
||||
return Actor(kind=caller.kind, uid=caller.uid, username=caller.username)
|
||||
user = get_current_user(scope)
|
||||
if user and is_admin(user):
|
||||
return Actor(kind="admin", uid=user["uid"], username=user.get("username", ""))
|
||||
key = _bearer_key(scope)
|
||||
if key and key == internal_gateway_key():
|
||||
return Actor(kind="internal", uid="internal", username="internal")
|
||||
if key:
|
||||
keyed = _user_from_api_key(key)
|
||||
if keyed and is_admin(keyed):
|
||||
return Actor(
|
||||
kind="admin", uid=keyed["uid"], username=keyed.get("username", "")
|
||||
)
|
||||
if user:
|
||||
kind = "admin" if is_admin(user) else "user"
|
||||
return Actor(kind=kind, uid=user["uid"], username=user.get("username", ""))
|
||||
return Actor(kind="user", uid=user["uid"], username=user.get("username", ""))
|
||||
return Actor(kind="guest", uid="", username="guest")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["TelegramService"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "TelegramService":
|
||||
from .service import TelegramService
|
||||
|
||||
return TelegramService
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,142 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from devplacepy import stealth
|
||||
|
||||
API_ROOT = "https://api.telegram.org"
|
||||
|
||||
|
||||
class TelegramBackend(ABC):
|
||||
@abstractmethod
|
||||
async def get_updates(self, offset: int, timeout: int) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def send_message(
|
||||
self, chat_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def edit_message_text(
|
||||
self, chat_id: int, message_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def send_chat_action(self, chat_id: int, action: str) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get_file(self, file_id: str) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def download(self, file_path: str) -> bytes: ...
|
||||
|
||||
|
||||
class HttpTelegramBackend(TelegramBackend):
|
||||
def __init__(self, token: str, timeout: float = 60.0) -> None:
|
||||
self._token = token
|
||||
self._timeout = timeout
|
||||
|
||||
def _method_url(self, method: str) -> str:
|
||||
return f"{API_ROOT}/bot{self._token}/{method}"
|
||||
|
||||
async def _call(self, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||
async with stealth.stealth_async_client(timeout=timeout) as client:
|
||||
response = await client.post(self._method_url(method), json=payload)
|
||||
try:
|
||||
return response.json()
|
||||
except (ValueError, TypeError):
|
||||
return {"ok": False, "description": f"non-json response ({response.status_code})"}
|
||||
|
||||
async def get_updates(self, offset: int, timeout: int) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"getUpdates",
|
||||
{"offset": offset, "timeout": timeout, "allowed_updates": ["message"]},
|
||||
timeout=timeout + 15,
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self, chat_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
if parse_mode:
|
||||
payload["parse_mode"] = parse_mode
|
||||
return await self._call("sendMessage", payload, self._timeout)
|
||||
|
||||
async def edit_message_text(
|
||||
self, chat_id: int, message_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"text": text,
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
if parse_mode:
|
||||
payload["parse_mode"] = parse_mode
|
||||
return await self._call("editMessageText", payload, self._timeout)
|
||||
|
||||
async def send_chat_action(self, chat_id: int, action: str) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"sendChatAction", {"chat_id": chat_id, "action": action}, self._timeout
|
||||
)
|
||||
|
||||
async def get_file(self, file_id: str) -> dict[str, Any]:
|
||||
return await self._call("getFile", {"file_id": file_id}, self._timeout)
|
||||
|
||||
async def download(self, file_path: str) -> bytes:
|
||||
url = f"{API_ROOT}/file/bot{self._token}/{file_path}"
|
||||
async with stealth.stealth_async_client(timeout=self._timeout) as client:
|
||||
response = await client.get(url)
|
||||
if response.status_code != 200:
|
||||
return b""
|
||||
return response.content
|
||||
|
||||
|
||||
class FakeTelegramBackend(TelegramBackend):
|
||||
def __init__(self) -> None:
|
||||
self.inbound: list[dict[str, Any]] = []
|
||||
self.sent: list[dict[str, Any]] = []
|
||||
self.edited: list[dict[str, Any]] = []
|
||||
self.actions: list[dict[str, Any]] = []
|
||||
self.files: dict[str, bytes] = {}
|
||||
self._message_id = 1000
|
||||
|
||||
async def get_updates(self, offset: int, timeout: int) -> dict[str, Any]:
|
||||
ready = [u for u in self.inbound if int(u.get("update_id", 0)) >= offset]
|
||||
self.inbound = []
|
||||
return {"ok": True, "result": ready}
|
||||
|
||||
async def send_message(
|
||||
self, chat_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]:
|
||||
self._message_id += 1
|
||||
self.sent.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
|
||||
return {"ok": True, "result": {"message_id": self._message_id}}
|
||||
|
||||
async def edit_message_text(
|
||||
self, chat_id: int, message_id: int, text: str, parse_mode: str | None
|
||||
) -> dict[str, Any]:
|
||||
self.edited.append(
|
||||
{"chat_id": chat_id, "message_id": message_id, "text": text, "parse_mode": parse_mode}
|
||||
)
|
||||
return {"ok": True, "result": {"message_id": message_id}}
|
||||
|
||||
async def send_chat_action(self, chat_id: int, action: str) -> dict[str, Any]:
|
||||
self.actions.append({"chat_id": chat_id, "action": action})
|
||||
return {"ok": True}
|
||||
|
||||
async def get_file(self, file_id: str) -> dict[str, Any]:
|
||||
if file_id not in self.files:
|
||||
return {"ok": False}
|
||||
return {"ok": True, "result": {"file_path": f"photos/{file_id}.jpg"}}
|
||||
|
||||
async def download(self, file_path: str) -> bytes:
|
||||
key = file_path.split("/")[-1].rsplit(".", 1)[0]
|
||||
return self.files.get(key, b"")
|
||||
@@ -0,0 +1,262 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.utils import is_admin, is_primary_admin
|
||||
|
||||
from . import store
|
||||
from .format import markdown_to_telegram_html, split_for_telegram
|
||||
|
||||
logger = logging.getLogger("telegram.bridge")
|
||||
|
||||
TURN_TIMEOUT_SECONDS = 300.0
|
||||
TYPING_INTERVAL_SECONDS = 4.0
|
||||
PROGRESS_EDIT_INTERVAL_SECONDS = 1.5
|
||||
ATTEMPT_LIMIT = 5
|
||||
ATTEMPT_WINDOW_SECONDS = 600.0
|
||||
THINKING_PLACEHOLDER = "<i>Devii is thinking...</i>"
|
||||
|
||||
WELCOME = (
|
||||
"Welcome to Devii on Telegram. To connect your DevPlace account, open your profile, "
|
||||
"request a Telegram pairing code, and send me the four digit code here."
|
||||
)
|
||||
PAIRED_TEMPLATE = "Paired. Hello {username}. Send me a message and I will help."
|
||||
BAD_CODE = (
|
||||
"That code is invalid or expired. Request a fresh code from your DevPlace profile and "
|
||||
"send it here."
|
||||
)
|
||||
TOO_MANY = "Too many attempts. Wait a few minutes, request a new code, and try again."
|
||||
|
||||
|
||||
class TelegramConnection:
|
||||
def __init__(self, service: Any, session: Any, chat_id: int, message_id: int | None) -> None:
|
||||
self._service = service
|
||||
self._session = session
|
||||
self._chat_id = chat_id
|
||||
self._message_id = message_id
|
||||
self.done = asyncio.Event()
|
||||
self._finalized = False
|
||||
self._last_status = ""
|
||||
self._last_edit = 0.0
|
||||
self._typing_task = asyncio.create_task(self._typing_loop())
|
||||
|
||||
async def send_json(self, payload: dict[str, Any]) -> None:
|
||||
kind = payload.get("type")
|
||||
if kind == "reply":
|
||||
await self._finalize(markdown_to_telegram_html(payload.get("text", "")) or "(no response)")
|
||||
elif kind == "error":
|
||||
await self._finalize(html.escape("Error: " + str(payload.get("text", ""))))
|
||||
elif kind in ("status", "trace"):
|
||||
await self._progress(payload)
|
||||
elif kind in ("avatar", "client"):
|
||||
query_id = payload.get("id")
|
||||
if query_id:
|
||||
self._session.resolve_query(
|
||||
query_id, {"error": "No browser is attached (Telegram session)."}
|
||||
)
|
||||
|
||||
async def _typing_loop(self) -> None:
|
||||
try:
|
||||
while not self.done.is_set():
|
||||
await self._service.chat_action(self._chat_id, "typing")
|
||||
await asyncio.sleep(TYPING_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 - typing is cosmetic, never fail a turn over it
|
||||
pass
|
||||
|
||||
async def _progress(self, payload: dict[str, Any]) -> None:
|
||||
if self._finalized or self._message_id is None:
|
||||
return
|
||||
if payload.get("type") == "trace":
|
||||
if payload.get("event") != "call":
|
||||
return
|
||||
text = f"Working... ({payload.get('name', '')})"
|
||||
else:
|
||||
text = str(payload.get("text", ""))
|
||||
if not text or text == self._last_status:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - self._last_edit < PROGRESS_EDIT_INTERVAL_SECONDS:
|
||||
return
|
||||
self._last_status = text
|
||||
self._last_edit = now
|
||||
await self._service.edit(
|
||||
self._chat_id, self._message_id, f"<i>{html.escape(text)}</i>"
|
||||
)
|
||||
|
||||
async def _finalize(self, html_text: str) -> None:
|
||||
if self._finalized:
|
||||
return
|
||||
self._finalized = True
|
||||
self._stop_typing()
|
||||
chunks = split_for_telegram(html_text)
|
||||
first = chunks[0] if chunks else "(no response)"
|
||||
if self._message_id is not None:
|
||||
ok = await self._service.edit(self._chat_id, self._message_id, first)
|
||||
if not ok:
|
||||
await self._service.send(self._chat_id, first)
|
||||
else:
|
||||
await self._service.send(self._chat_id, first)
|
||||
for chunk in chunks[1:]:
|
||||
await self._service.send(self._chat_id, chunk)
|
||||
self.done.set()
|
||||
|
||||
def _stop_typing(self) -> None:
|
||||
if self._typing_task is not None and not self._typing_task.done():
|
||||
self._typing_task.cancel()
|
||||
|
||||
|
||||
class TelegramBridge:
|
||||
def __init__(self, service: Any, max_concurrent: int = 8) -> None:
|
||||
self._service = service
|
||||
self._semaphore = asyncio.Semaphore(max(1, max_concurrent))
|
||||
self._chat_locks: dict[int, asyncio.Lock] = {}
|
||||
self._attempts: dict[int, list[float]] = {}
|
||||
|
||||
def _chat_lock(self, chat_id: int) -> asyncio.Lock:
|
||||
lock = self._chat_locks.get(chat_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
return lock
|
||||
|
||||
async def handle_inbound(self, event: dict[str, Any]) -> None:
|
||||
chat_id = int(event["chat_id"])
|
||||
from_id = int(event["from_id"])
|
||||
text = str(event.get("text", "")).strip()
|
||||
images = [img for img in event.get("images", []) if img]
|
||||
link = store.user_for_chat(chat_id)
|
||||
if link is None:
|
||||
await self._handle_unpaired(chat_id, from_id, text)
|
||||
return
|
||||
if int(link.get("from_id", 0)) != from_id:
|
||||
return
|
||||
user = get_table("users").find_one(uid=link["user_uid"])
|
||||
if not user:
|
||||
await self._service.send(
|
||||
chat_id, "Your DevPlace account was not found. Please re-pair from your profile."
|
||||
)
|
||||
return
|
||||
async with self._chat_lock(chat_id):
|
||||
async with self._semaphore:
|
||||
await self._run_turn(chat_id, user, text, images)
|
||||
|
||||
async def _handle_unpaired(self, chat_id: int, from_id: int, text: str) -> None:
|
||||
if text.isdigit() and len(text) == 4:
|
||||
if self._too_many_attempts(chat_id):
|
||||
await self._service.send(chat_id, TOO_MANY)
|
||||
return
|
||||
user = store.verify_code(text, chat_id, from_id)
|
||||
if user:
|
||||
self._attempts.pop(chat_id, None)
|
||||
await self._service.send(
|
||||
chat_id, PAIRED_TEMPLATE.format(username=user.get("username", "there"))
|
||||
)
|
||||
audit.record_system(
|
||||
"telegram.pair.success",
|
||||
actor_kind="user",
|
||||
actor_uid=user["uid"],
|
||||
actor_username=user.get("username", ""),
|
||||
summary=f"Telegram paired for {user.get('username', user['uid'])}",
|
||||
metadata={"chat_id": chat_id},
|
||||
)
|
||||
return
|
||||
self._register_attempt(chat_id)
|
||||
await self._service.send(chat_id, BAD_CODE)
|
||||
audit.record_system(
|
||||
"telegram.pair.failure",
|
||||
actor_kind="guest",
|
||||
result="failure",
|
||||
summary="Telegram pairing code rejected",
|
||||
metadata={"chat_id": chat_id},
|
||||
)
|
||||
return
|
||||
await self._service.send(chat_id, WELCOME)
|
||||
|
||||
def _too_many_attempts(self, chat_id: int) -> bool:
|
||||
now = time.monotonic()
|
||||
recent = [t for t in self._attempts.get(chat_id, []) if now - t < ATTEMPT_WINDOW_SECONDS]
|
||||
self._attempts[chat_id] = recent
|
||||
return len(recent) >= ATTEMPT_LIMIT
|
||||
|
||||
def _register_attempt(self, chat_id: int) -> None:
|
||||
self._attempts.setdefault(chat_id, []).append(time.monotonic())
|
||||
|
||||
async def _run_turn(
|
||||
self, chat_id: int, user: dict[str, Any], text: str, images: list[str]
|
||||
) -> None:
|
||||
devii = service_manager.get_service("devii")
|
||||
if devii is None or not devii.is_enabled():
|
||||
await self._service.send(chat_id, "Devii is currently unavailable.")
|
||||
return
|
||||
owner_id = user["uid"]
|
||||
owner_is_admin = is_admin(user)
|
||||
owner_is_primary_admin = is_primary_admin(user)
|
||||
if devii.quota_exceeded("user", owner_id, owner_is_admin):
|
||||
limit = devii.daily_limit_for("user", owner_is_admin)
|
||||
audit.record_system(
|
||||
"ai.quota.exceeded",
|
||||
actor_kind="user",
|
||||
actor_uid=owner_id,
|
||||
actor_username=user.get("username", ""),
|
||||
actor_role="admin" if owner_is_admin else "member",
|
||||
origin="telegram",
|
||||
via_agent=1,
|
||||
result="denied",
|
||||
summary=f"Telegram AI request by {user.get('username', owner_id)} blocked - 24h quota reached",
|
||||
metadata={"limit_usd": limit},
|
||||
)
|
||||
await self._service.send(
|
||||
chat_id, "Your daily AI quota is reached (100%). Please try again later."
|
||||
)
|
||||
return
|
||||
session = devii.hub().get_or_create(
|
||||
"user",
|
||||
owner_id,
|
||||
user.get("username", ""),
|
||||
user.get("api_key", ""),
|
||||
devii.instance_base_url(),
|
||||
is_admin=owner_is_admin,
|
||||
is_primary_admin=owner_is_primary_admin,
|
||||
channel="telegram",
|
||||
)
|
||||
session.set_timezone(user.get("timezone") or "")
|
||||
placeholder_id = await self._service.send(chat_id, THINKING_PLACEHOLDER)
|
||||
connection = TelegramConnection(self._service, session, chat_id, placeholder_id)
|
||||
session.attach(connection)
|
||||
content, audit_text = self._build_content(text, images)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
session.spawn_turn(content, audit_text=audit_text)
|
||||
await asyncio.wait_for(connection.done.wait(), timeout=TURN_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
await connection._finalize(
|
||||
html.escape("Devii took too long to respond. Please try again.")
|
||||
)
|
||||
except Exception: # noqa: BLE001 - never let one turn crash the bridge
|
||||
logger.exception("Telegram turn failed for %s", owner_id)
|
||||
await connection._finalize(html.escape("Something went wrong handling your message."))
|
||||
finally:
|
||||
session.detach(connection)
|
||||
self._service.record_latency(time.monotonic() - started)
|
||||
|
||||
@staticmethod
|
||||
def _build_content(text: str, images: list[str]) -> tuple[Any, str]:
|
||||
if not images:
|
||||
return text, text
|
||||
prompt = text or "Look at the attached image and respond."
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||
for uri in images:
|
||||
content.append({"type": "image_url", "image_url": {"url": uri}})
|
||||
audit_text = f"{text} [{len(images)} image(s)]".strip()
|
||||
return content, audit_text
|
||||
@@ -0,0 +1,164 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
|
||||
import mistune
|
||||
|
||||
MAX_MESSAGE_CHARS = 4096
|
||||
CHUNK_BUDGET = 3900
|
||||
|
||||
_markdown = mistune.create_markdown(
|
||||
escape=False,
|
||||
hard_wrap=True,
|
||||
plugins=["table", "strikethrough", "url"],
|
||||
)
|
||||
|
||||
INLINE_TAGS = {
|
||||
"strong": "b",
|
||||
"b": "b",
|
||||
"em": "i",
|
||||
"i": "i",
|
||||
"del": "s",
|
||||
"s": "s",
|
||||
"strike": "s",
|
||||
"u": "u",
|
||||
"ins": "u",
|
||||
"code": "code",
|
||||
"pre": "pre",
|
||||
"blockquote": "blockquote",
|
||||
}
|
||||
|
||||
BLOCK_TAGS = {"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "table", "tr"}
|
||||
HEADING_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6"}
|
||||
|
||||
|
||||
class _TelegramHtml(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self._out: list[str] = []
|
||||
self._list_stack: list[dict] = []
|
||||
self._in_heading = False
|
||||
self._in_pre = False
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list) -> None:
|
||||
mapping = dict(attrs)
|
||||
if tag == "br":
|
||||
self._out.append("\n")
|
||||
return
|
||||
if tag == "hr":
|
||||
self._out.append("\n")
|
||||
return
|
||||
if tag in HEADING_TAGS:
|
||||
self._newline_break()
|
||||
self._out.append("<b>")
|
||||
self._in_heading = True
|
||||
return
|
||||
if tag in ("ul", "ol"):
|
||||
self._newline_break()
|
||||
self._list_stack.append({"ordered": tag == "ol", "n": 0})
|
||||
return
|
||||
if tag == "li":
|
||||
self._out.append("\n")
|
||||
if self._list_stack:
|
||||
top = self._list_stack[-1]
|
||||
if top["ordered"]:
|
||||
top["n"] += 1
|
||||
self._out.append(f"{top['n']}. ")
|
||||
else:
|
||||
self._out.append("- ")
|
||||
return
|
||||
if tag in BLOCK_TAGS:
|
||||
self._newline_break()
|
||||
return
|
||||
if tag == "a":
|
||||
href = mapping.get("href", "")
|
||||
if href.startswith(("http://", "https://", "tg://")):
|
||||
self._out.append(f'<a href="{html.escape(href, quote=True)}">')
|
||||
return
|
||||
if tag == "pre":
|
||||
self._newline_break()
|
||||
self._out.append("<pre>")
|
||||
self._in_pre = True
|
||||
return
|
||||
target = INLINE_TAGS.get(tag)
|
||||
if target:
|
||||
self._out.append(f"<{target}>")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in HEADING_TAGS:
|
||||
self._out.append("</b>\n")
|
||||
self._in_heading = False
|
||||
return
|
||||
if tag in ("ul", "ol"):
|
||||
if self._list_stack:
|
||||
self._list_stack.pop()
|
||||
self._out.append("\n")
|
||||
return
|
||||
if tag in BLOCK_TAGS or tag == "li":
|
||||
return
|
||||
if tag == "a":
|
||||
if self._out and "<a href=" in "".join(self._out[-6:]):
|
||||
self._out.append("</a>")
|
||||
return
|
||||
if tag == "pre":
|
||||
self._out.append("</pre>\n")
|
||||
self._in_pre = False
|
||||
return
|
||||
target = INLINE_TAGS.get(tag)
|
||||
if target:
|
||||
self._out.append(f"</{target}>")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self._out.append(html.escape(data, quote=False))
|
||||
|
||||
def _newline_break(self) -> None:
|
||||
text = "".join(self._out)
|
||||
if text and not text.endswith("\n\n"):
|
||||
if text.endswith("\n"):
|
||||
self._out.append("\n")
|
||||
else:
|
||||
self._out.append("\n\n")
|
||||
|
||||
def result(self) -> str:
|
||||
text = "".join(self._out)
|
||||
while "\n\n\n" in text:
|
||||
text = text.replace("\n\n\n", "\n\n")
|
||||
return text.strip()
|
||||
|
||||
|
||||
def markdown_to_telegram_html(text: str) -> str:
|
||||
rendered = _markdown(text or "")
|
||||
parser = _TelegramHtml()
|
||||
parser.feed(rendered)
|
||||
parser.close()
|
||||
return parser.result()
|
||||
|
||||
|
||||
def split_for_telegram(text: str, limit: int = CHUNK_BUDGET) -> list[str]:
|
||||
if len(text) <= limit:
|
||||
return [text] if text else [""]
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for line in text.split("\n"):
|
||||
piece = line if not current else "\n" + line
|
||||
if len(current) + len(piece) <= limit:
|
||||
current += piece
|
||||
continue
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = ""
|
||||
if len(line) <= limit:
|
||||
current = line
|
||||
continue
|
||||
for start in range(0, len(line), limit):
|
||||
segment = line[start : start + limit]
|
||||
if len(segment) == limit:
|
||||
chunks.append(segment)
|
||||
else:
|
||||
current = segment
|
||||
if current:
|
||||
chunks.append(current)
|
||||
return chunks or [""]
|
||||
@@ -0,0 +1,368 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from devplacepy.config import BASE_DIR
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.services.base import BaseService, ConfigField
|
||||
|
||||
from .bridge import TelegramBridge
|
||||
from .format import markdown_to_telegram_html, split_for_telegram
|
||||
|
||||
logger = logging.getLogger("telegram.service")
|
||||
|
||||
WORKER_MODULE = "devplacepy.services.telegram.worker"
|
||||
STREAM_LIMIT = 16 * 1024 * 1024
|
||||
RESULT_TIMEOUT_SECONDS = 30.0
|
||||
STOP_GRACE_SECONDS = 5.0
|
||||
LIVE_LOG_SIZE = 200
|
||||
LATENCY_SAMPLES = 200
|
||||
|
||||
FIELD_TOKEN = "telegram_bot_token"
|
||||
FIELD_POLL_TIMEOUT = "telegram_poll_timeout"
|
||||
FIELD_CODE_TTL = "telegram_code_ttl_minutes"
|
||||
FIELD_MAX_CONCURRENT = "telegram_max_concurrent_turns"
|
||||
|
||||
|
||||
class TelegramService(BaseService):
|
||||
default_enabled = False
|
||||
min_interval = 15
|
||||
title = "Telegram Bot"
|
||||
description = (
|
||||
"Runs a Telegram bot that bridges Devii into Telegram. A user pairs their Telegram "
|
||||
"account with a four digit code from their profile, then chats with their own Devii "
|
||||
"with markdown, typing status, live message editing and image understanding. The "
|
||||
"long-poller runs as a supervised subprocess and is off by default."
|
||||
)
|
||||
config_fields = [
|
||||
ConfigField(
|
||||
FIELD_TOKEN,
|
||||
"Bot token",
|
||||
type="str",
|
||||
default="",
|
||||
secret=True,
|
||||
help="Telegram bot token from @BotFather. Required for the service to start.",
|
||||
group="Telegram",
|
||||
),
|
||||
ConfigField(
|
||||
FIELD_POLL_TIMEOUT,
|
||||
"Long-poll timeout (seconds)",
|
||||
type="int",
|
||||
default=25,
|
||||
minimum=1,
|
||||
maximum=50,
|
||||
help="getUpdates long-poll hold time. 25-30 is recommended.",
|
||||
group="Telegram",
|
||||
),
|
||||
ConfigField(
|
||||
FIELD_CODE_TTL,
|
||||
"Pairing code lifetime (minutes)",
|
||||
type="int",
|
||||
default=60,
|
||||
minimum=1,
|
||||
maximum=1440,
|
||||
help="How long a pairing code requested from a profile stays valid.",
|
||||
group="Telegram",
|
||||
),
|
||||
ConfigField(
|
||||
FIELD_MAX_CONCURRENT,
|
||||
"Max concurrent turns",
|
||||
type="int",
|
||||
default=8,
|
||||
minimum=1,
|
||||
maximum=64,
|
||||
help="Upper bound on Devii turns processed at once across all chats.",
|
||||
group="Telegram",
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(name="telegram", interval_seconds=30)
|
||||
self._proc: asyncio.subprocess.Process | None = None
|
||||
self._tasks: list[asyncio.Task] = []
|
||||
self._pending: dict[int, asyncio.Future] = {}
|
||||
self._req_seq = 0
|
||||
self._stdin_lock = asyncio.Lock()
|
||||
self._live_logs: deque[str] = deque(maxlen=LIVE_LOG_SIZE)
|
||||
self._bridge: TelegramBridge | None = None
|
||||
self._stats = self._fresh_stats()
|
||||
|
||||
def _fresh_stats(self) -> dict:
|
||||
return {"in": 0, "out": 0, "edits": 0, "errors": 0, "latencies": deque(maxlen=LATENCY_SAMPLES)}
|
||||
|
||||
def _token(self) -> str:
|
||||
return get_setting(FIELD_TOKEN, "").strip()
|
||||
|
||||
def _alive(self) -> bool:
|
||||
return self._proc is not None and self._proc.returncode is None
|
||||
|
||||
async def on_enable(self) -> None:
|
||||
if not self._token():
|
||||
self.log("No bot token configured; waiting for configuration")
|
||||
return
|
||||
await self._spawn()
|
||||
|
||||
async def on_disable(self) -> None:
|
||||
await self._terminate()
|
||||
|
||||
async def run_once(self) -> None:
|
||||
if not self.is_enabled():
|
||||
return
|
||||
if not self._token():
|
||||
return
|
||||
if not self._alive():
|
||||
self.log("Worker not running; starting it")
|
||||
await self._spawn()
|
||||
|
||||
async def _spawn(self) -> None:
|
||||
if self._alive():
|
||||
return
|
||||
self._bridge = TelegramBridge(self, max_concurrent=self._max_concurrent())
|
||||
self._stats = self._fresh_stats()
|
||||
env = {
|
||||
**os.environ,
|
||||
"TELEGRAM_BOT_TOKEN": self._token(),
|
||||
"TELEGRAM_POLL_TIMEOUT": str(self._poll_timeout()),
|
||||
}
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
WORKER_MODULE,
|
||||
cwd=str(BASE_DIR),
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
limit=STREAM_LIMIT,
|
||||
)
|
||||
self._tasks = [
|
||||
asyncio.create_task(self._read_stdout()),
|
||||
asyncio.create_task(self._read_stderr()),
|
||||
]
|
||||
self.log(f"Telegram worker started (pid {self._proc.pid})")
|
||||
|
||||
async def _terminate(self) -> None:
|
||||
proc = self._proc
|
||||
self._proc = None
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
self._tasks = []
|
||||
for future in self._pending.values():
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
self._pending = {}
|
||||
if proc is None:
|
||||
return
|
||||
try:
|
||||
if proc.stdin is not None and not proc.stdin.is_closing():
|
||||
proc.stdin.close()
|
||||
except Exception: # noqa: BLE001 - closing a dead pipe is fine
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=STOP_GRACE_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
self._kill_group(proc)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=STOP_GRACE_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
self.log("Telegram worker stopped")
|
||||
|
||||
def _kill_group(self, proc: asyncio.subprocess.Process) -> None:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
async def _read_stdout(self) -> None:
|
||||
proc = self._proc
|
||||
if proc is None or proc.stdout is None:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
frame = json.loads(line.decode("utf-8", "replace"))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
await self._handle_frame(frame)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 - reader must not crash the supervisor
|
||||
logger.exception("Telegram stdout reader failed")
|
||||
|
||||
async def _read_stderr(self) -> None:
|
||||
proc = self._proc
|
||||
if proc is None or proc.stderr is None:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
line = await proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
self._live_log("[stderr] " + line.decode("utf-8", "replace").rstrip())
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
async def _handle_frame(self, frame: dict) -> None:
|
||||
kind = frame.get("type")
|
||||
if kind == "log":
|
||||
self._live_log(str(frame.get("line", "")))
|
||||
elif kind == "ready":
|
||||
self._live_log("worker ready")
|
||||
elif kind == "result":
|
||||
self._resolve(frame)
|
||||
elif kind == "message":
|
||||
self._stats["in"] += 1
|
||||
self._live_log(f"in <- chat {frame.get('chat_id')}")
|
||||
if self._bridge is not None:
|
||||
asyncio.create_task(self._dispatch_inbound(frame))
|
||||
|
||||
async def _dispatch_inbound(self, frame: dict) -> None:
|
||||
try:
|
||||
await self._bridge.handle_inbound(frame)
|
||||
except Exception: # noqa: BLE001 - surfaced as a stat, never crashes the reader
|
||||
self._stats["errors"] += 1
|
||||
logger.exception("Telegram inbound handling failed")
|
||||
|
||||
def _resolve(self, frame: dict) -> None:
|
||||
future = self._pending.pop(frame.get("req_id"), None)
|
||||
if future is not None and not future.done():
|
||||
future.set_result(frame)
|
||||
|
||||
def _live_log(self, line: str) -> None:
|
||||
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
entry = f"[{stamp}] {line}"
|
||||
self._live_logs.append(entry)
|
||||
asyncio.create_task(self._publish_log(entry))
|
||||
|
||||
async def _publish_log(self, entry: str) -> None:
|
||||
try:
|
||||
from devplacepy.services.pubsub import publish
|
||||
|
||||
await publish(f"admin.services.{self.name}.logs", {"line": entry})
|
||||
except Exception: # noqa: BLE001 - live logs are best-effort
|
||||
pass
|
||||
|
||||
async def _write(self, command: dict) -> None:
|
||||
proc = self._proc
|
||||
if proc is None or proc.stdin is None or proc.stdin.is_closing():
|
||||
raise RuntimeError("Telegram worker is not running")
|
||||
payload = (json.dumps(command, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
async with self._stdin_lock:
|
||||
proc.stdin.write(payload)
|
||||
await proc.stdin.drain()
|
||||
|
||||
def _next_req(self) -> int:
|
||||
self._req_seq += 1
|
||||
return self._req_seq
|
||||
|
||||
async def send(self, chat_id: int, text: str, parse_mode: str = "HTML") -> int | None:
|
||||
req_id = self._next_req()
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending[req_id] = future
|
||||
try:
|
||||
await self._write(
|
||||
{"cmd": "send", "req_id": req_id, "chat_id": chat_id, "text": text, "parse_mode": parse_mode}
|
||||
)
|
||||
except RuntimeError:
|
||||
self._pending.pop(req_id, None)
|
||||
self._stats["errors"] += 1
|
||||
return None
|
||||
self._stats["out"] += 1
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=RESULT_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
self._pending.pop(req_id, None)
|
||||
self._stats["errors"] += 1
|
||||
return None
|
||||
if not result.get("ok"):
|
||||
self._stats["errors"] += 1
|
||||
return None
|
||||
return result.get("message_id")
|
||||
|
||||
async def edit(self, chat_id: int, message_id: int, text: str, parse_mode: str = "HTML") -> bool:
|
||||
req_id = self._next_req()
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending[req_id] = future
|
||||
try:
|
||||
await self._write(
|
||||
{
|
||||
"cmd": "edit",
|
||||
"req_id": req_id,
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"text": text,
|
||||
"parse_mode": parse_mode,
|
||||
}
|
||||
)
|
||||
except RuntimeError:
|
||||
self._pending.pop(req_id, None)
|
||||
return False
|
||||
self._stats["edits"] += 1
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=RESULT_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
self._pending.pop(req_id, None)
|
||||
return False
|
||||
return bool(result.get("ok"))
|
||||
|
||||
async def chat_action(self, chat_id: int, action: str = "typing") -> None:
|
||||
try:
|
||||
await self._write({"cmd": "chat_action", "chat_id": chat_id, "action": action})
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
async def send_markdown(self, chat_id: int, text: str) -> bool:
|
||||
chunks = split_for_telegram(markdown_to_telegram_html(text))
|
||||
delivered = False
|
||||
for chunk in chunks:
|
||||
if await self.send(chat_id, chunk) is not None:
|
||||
delivered = True
|
||||
return delivered
|
||||
|
||||
def record_latency(self, seconds: float) -> None:
|
||||
self._stats["latencies"].append(seconds)
|
||||
|
||||
def _poll_timeout(self) -> int:
|
||||
return max(1, int(self.get_config().get(FIELD_POLL_TIMEOUT, 25)))
|
||||
|
||||
def _max_concurrent(self) -> int:
|
||||
return max(1, int(self.get_config().get(FIELD_MAX_CONCURRENT, 8)))
|
||||
|
||||
def worker_alive(self) -> bool:
|
||||
return self._alive()
|
||||
|
||||
def collect_metrics(self) -> dict:
|
||||
latencies = list(self._stats["latencies"])
|
||||
avg_ms = int(sum(latencies) / len(latencies) * 1000) if latencies else 0
|
||||
return {
|
||||
"stats": [
|
||||
{"label": "Worker", "value": "running" if self._alive() else "stopped"},
|
||||
{"label": "Messages in", "value": self._stats["in"]},
|
||||
{"label": "Messages out", "value": self._stats["out"]},
|
||||
{"label": "Edits", "value": self._stats["edits"]},
|
||||
{"label": "Errors", "value": self._stats["errors"]},
|
||||
{"label": "Avg response", "value": f"{avg_ms} ms"},
|
||||
{"label": "Token", "value": "set" if self._token() else "missing"},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import get_int_setting, get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
CODE_TTL_SETTING = "telegram_code_ttl_minutes"
|
||||
DEFAULT_CODE_TTL_MINUTES = 60
|
||||
PAIRINGS = "telegram_pairings"
|
||||
LINKS = "telegram_links"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _hash(code: str) -> str:
|
||||
return hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _parse(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _ttl_minutes() -> int:
|
||||
return max(1, get_int_setting(CODE_TTL_SETTING, DEFAULT_CODE_TTL_MINUTES))
|
||||
|
||||
|
||||
def _active_code_hashes() -> set[str]:
|
||||
table = get_table(PAIRINGS)
|
||||
now = _now()
|
||||
active: set[str] = set()
|
||||
for row in table.find(used=0):
|
||||
try:
|
||||
if _parse(row["expires_at"]) > now:
|
||||
active.add(row["code_hash"])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue
|
||||
return active
|
||||
|
||||
|
||||
def _evict_oldest_active() -> None:
|
||||
table = get_table(PAIRINGS)
|
||||
oldest = None
|
||||
for row in table.find(used=0):
|
||||
created = row.get("created_at") or ""
|
||||
if oldest is None or created < (oldest.get("created_at") or ""):
|
||||
oldest = row
|
||||
if oldest is not None:
|
||||
table.update({"id": oldest["id"], "used": 1}, ["id"])
|
||||
|
||||
|
||||
def _unique_code() -> str:
|
||||
for _ in range(2):
|
||||
taken = _active_code_hashes()
|
||||
for _ in range(256):
|
||||
candidate = f"{secrets.randbelow(10000):04d}"
|
||||
if _hash(candidate) not in taken:
|
||||
return candidate
|
||||
_evict_oldest_active()
|
||||
raise RuntimeError("Telegram pairing code pool is exhausted")
|
||||
|
||||
|
||||
def issue_code(user_uid: str) -> dict[str, Any]:
|
||||
table = get_table(PAIRINGS)
|
||||
for row in table.find(user_uid=user_uid, used=0):
|
||||
table.update({"id": row["id"], "used": 1}, ["id"])
|
||||
code = _unique_code()
|
||||
ttl = _ttl_minutes()
|
||||
expires_at = _now() + timedelta(minutes=ttl)
|
||||
table.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"code_hash": _hash(code),
|
||||
"expires_at": _iso(expires_at),
|
||||
"used": 0,
|
||||
"created_at": _iso(_now()),
|
||||
}
|
||||
)
|
||||
return {"code": code, "expires_at": _iso(expires_at), "ttl_minutes": ttl}
|
||||
|
||||
|
||||
def verify_code(code: str, chat_id: int, from_id: int) -> dict[str, Any] | None:
|
||||
cleaned = code.strip()
|
||||
if not (cleaned.isdigit() and len(cleaned) == 4):
|
||||
return None
|
||||
table = get_table(PAIRINGS)
|
||||
match = table.find_one(code_hash=_hash(cleaned), used=0)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
if _parse(match["expires_at"]) < _now():
|
||||
return None
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return None
|
||||
table.update({"id": match["id"], "used": 1}, ["id"])
|
||||
bind(match["user_uid"], chat_id, from_id)
|
||||
users = get_table("users")
|
||||
return users.find_one(uid=match["user_uid"])
|
||||
|
||||
|
||||
def bind(user_uid: str, chat_id: int, from_id: int) -> None:
|
||||
table = get_table(LINKS)
|
||||
for row in table.find(user_uid=user_uid):
|
||||
table.delete(id=row["id"])
|
||||
for row in table.find(chat_id=chat_id):
|
||||
table.delete(id=row["id"])
|
||||
table.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"chat_id": int(chat_id),
|
||||
"from_id": int(from_id),
|
||||
"created_at": _iso(_now()),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def link_for_user(user_uid: str) -> dict[str, Any] | None:
|
||||
return get_table(LINKS).find_one(user_uid=user_uid)
|
||||
|
||||
|
||||
def user_for_chat(chat_id: int) -> dict[str, Any] | None:
|
||||
return get_table(LINKS).find_one(chat_id=int(chat_id))
|
||||
|
||||
|
||||
def unpair(user_uid: str) -> int:
|
||||
table = get_table(LINKS)
|
||||
removed = 0
|
||||
for row in table.find(user_uid=user_uid):
|
||||
table.delete(id=row["id"])
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def is_paired(user_uid: str) -> bool:
|
||||
return link_for_user(user_uid) is not None
|
||||
@@ -0,0 +1,304 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from .backend import HttpTelegramBackend, TelegramBackend
|
||||
|
||||
DEFAULT_POLL_TIMEOUT = 25
|
||||
DEFAULT_IMAGE_MAX_BYTES = 2_000_000
|
||||
DEFAULT_MIN_SEND_INTERVAL = 0.05
|
||||
CONFLICT_BACKOFF_SECONDS = 3.0
|
||||
ERROR_BACKOFF_SECONDS = 2.0
|
||||
_TAG = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _guess_mime(file_path: str) -> str:
|
||||
lowered = file_path.lower()
|
||||
if lowered.endswith(".png"):
|
||||
return "image/png"
|
||||
if lowered.endswith(".webp"):
|
||||
return "image/webp"
|
||||
if lowered.endswith(".gif"):
|
||||
return "image/gif"
|
||||
return "image/jpeg"
|
||||
|
||||
|
||||
def _strip_tags(text: str) -> str:
|
||||
import html as _html
|
||||
|
||||
return _html.unescape(_TAG.sub("", text))
|
||||
|
||||
|
||||
def _entity_error(resp: dict[str, Any]) -> bool:
|
||||
return "can't parse entities" in str(resp.get("description", "")).lower()
|
||||
|
||||
|
||||
class TelegramWorker:
|
||||
def __init__(
|
||||
self,
|
||||
backend: TelegramBackend,
|
||||
on_emit: Callable[[dict[str, Any]], Awaitable[None]],
|
||||
*,
|
||||
poll_timeout: int = DEFAULT_POLL_TIMEOUT,
|
||||
image_max_bytes: int = DEFAULT_IMAGE_MAX_BYTES,
|
||||
min_send_interval: float = DEFAULT_MIN_SEND_INTERVAL,
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._on_emit = on_emit
|
||||
self._poll_timeout = poll_timeout
|
||||
self._image_max_bytes = image_max_bytes
|
||||
self._min_send_interval = min_send_interval
|
||||
self._offset = 0
|
||||
self._stop = asyncio.Event()
|
||||
self._gate = asyncio.Lock()
|
||||
self._last_call = 0.0
|
||||
|
||||
async def _emit(self, frame: dict[str, Any]) -> None:
|
||||
await self._on_emit(frame)
|
||||
|
||||
async def _log(self, line: str) -> None:
|
||||
await self._emit({"type": "log", "line": line})
|
||||
|
||||
async def _throttle(self) -> None:
|
||||
async with self._gate:
|
||||
wait = self._min_send_interval - (time.monotonic() - self._last_call)
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
self._last_call = time.monotonic()
|
||||
|
||||
async def poll_once(self) -> list[dict[str, Any]]:
|
||||
resp = await self._backend.get_updates(self._offset, self._poll_timeout)
|
||||
if not resp.get("ok"):
|
||||
if resp.get("error_code") == 409:
|
||||
await self._log("getUpdates conflict (409); backing off")
|
||||
await asyncio.sleep(CONFLICT_BACKOFF_SECONDS)
|
||||
else:
|
||||
await self._log(f"getUpdates error: {resp.get('description', 'unknown')}")
|
||||
await asyncio.sleep(ERROR_BACKOFF_SECONDS)
|
||||
return []
|
||||
updates = resp.get("result", []) or []
|
||||
for update in updates:
|
||||
self._offset = max(self._offset, int(update.get("update_id", 0)) + 1)
|
||||
return updates
|
||||
|
||||
async def process_update(self, update: dict[str, Any]) -> None:
|
||||
message = update.get("message")
|
||||
if not isinstance(message, dict):
|
||||
return
|
||||
chat = message.get("chat", {})
|
||||
if chat.get("type") != "private":
|
||||
return
|
||||
chat_id = chat.get("id")
|
||||
from_id = (message.get("from") or {}).get("id")
|
||||
if chat_id is None or from_id is None:
|
||||
return
|
||||
text = message.get("text") or message.get("caption") or ""
|
||||
images: list[str] = []
|
||||
photos = message.get("photo")
|
||||
if isinstance(photos, list) and photos:
|
||||
largest = max(photos, key=lambda p: p.get("file_size", 0) or 0)
|
||||
data_uri = await self._fetch_image(largest.get("file_id"))
|
||||
if data_uri:
|
||||
images.append(data_uri)
|
||||
document = message.get("document")
|
||||
if isinstance(document, dict) and str(document.get("mime_type", "")).startswith(
|
||||
"image/"
|
||||
):
|
||||
data_uri = await self._fetch_image(
|
||||
document.get("file_id"), document.get("mime_type")
|
||||
)
|
||||
if data_uri:
|
||||
images.append(data_uri)
|
||||
await self._emit(
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"from_id": from_id,
|
||||
"text": text,
|
||||
"images": images,
|
||||
}
|
||||
)
|
||||
|
||||
async def _fetch_image(self, file_id: str | None, mime: str | None = None) -> str:
|
||||
if not file_id:
|
||||
return ""
|
||||
info = await self._backend.get_file(file_id)
|
||||
if not info.get("ok"):
|
||||
return ""
|
||||
file_path = (info.get("result") or {}).get("file_path", "")
|
||||
if not file_path:
|
||||
return ""
|
||||
data = await self._backend.download(file_path)
|
||||
if not data:
|
||||
return ""
|
||||
if len(data) > self._image_max_bytes:
|
||||
await self._log(f"image skipped ({len(data)} bytes over cap)")
|
||||
return ""
|
||||
resolved_mime = mime or _guess_mime(file_path)
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
return f"data:{resolved_mime};base64,{encoded}"
|
||||
|
||||
async def handle_command(self, command: dict[str, Any]) -> None:
|
||||
kind = command.get("cmd")
|
||||
try:
|
||||
if kind == "send":
|
||||
await self._send(command)
|
||||
elif kind == "edit":
|
||||
await self._edit(command)
|
||||
elif kind == "chat_action":
|
||||
await self._chat_action(command)
|
||||
except Exception as exc: # noqa: BLE001 - one bad command must not kill the worker
|
||||
await self._log(f"command {kind} failed: {exc}")
|
||||
req_id = command.get("req_id")
|
||||
if req_id is not None:
|
||||
await self._emit({"type": "result", "req_id": req_id, "ok": False})
|
||||
|
||||
async def _send(self, command: dict[str, Any]) -> None:
|
||||
chat_id = command["chat_id"]
|
||||
text = str(command.get("text", ""))
|
||||
parse_mode = command.get("parse_mode")
|
||||
await self._throttle()
|
||||
resp = await self._backend.send_message(chat_id, text, parse_mode)
|
||||
resp = await self._retry_if_needed(
|
||||
resp, lambda mode: self._backend.send_message(chat_id, text, mode), parse_mode
|
||||
)
|
||||
await self._emit_result(command.get("req_id"), resp)
|
||||
|
||||
async def _edit(self, command: dict[str, Any]) -> None:
|
||||
chat_id = command["chat_id"]
|
||||
message_id = command["message_id"]
|
||||
text = str(command.get("text", ""))
|
||||
parse_mode = command.get("parse_mode")
|
||||
await self._throttle()
|
||||
resp = await self._backend.edit_message_text(chat_id, message_id, text, parse_mode)
|
||||
resp = await self._retry_if_needed(
|
||||
resp,
|
||||
lambda mode: self._backend.edit_message_text(chat_id, message_id, text, mode),
|
||||
parse_mode,
|
||||
)
|
||||
await self._emit_result(command.get("req_id"), resp)
|
||||
|
||||
async def _chat_action(self, command: dict[str, Any]) -> None:
|
||||
await self._throttle()
|
||||
await self._backend.send_chat_action(command["chat_id"], command.get("action", "typing"))
|
||||
|
||||
async def _retry_if_needed(
|
||||
self,
|
||||
resp: dict[str, Any],
|
||||
again: Callable[[str | None], Awaitable[dict[str, Any]]],
|
||||
parse_mode: str | None,
|
||||
) -> dict[str, Any]:
|
||||
if resp.get("ok"):
|
||||
return resp
|
||||
if resp.get("error_code") == 429:
|
||||
retry_after = int((resp.get("parameters") or {}).get("retry_after", 1))
|
||||
await asyncio.sleep(min(retry_after, 30) + 0.25)
|
||||
await self._throttle()
|
||||
resp = await again(parse_mode)
|
||||
if resp.get("ok"):
|
||||
return resp
|
||||
if parse_mode and _entity_error(resp):
|
||||
await self._throttle()
|
||||
resp = await again(None)
|
||||
return resp
|
||||
|
||||
async def _emit_result(self, req_id: Any, resp: dict[str, Any]) -> None:
|
||||
if req_id is None:
|
||||
if not resp.get("ok"):
|
||||
await self._log(f"send failed: {resp.get('description', 'unknown')}")
|
||||
return
|
||||
message_id = None
|
||||
if resp.get("ok"):
|
||||
message_id = (resp.get("result") or {}).get("message_id")
|
||||
await self._emit(
|
||||
{"type": "result", "req_id": req_id, "ok": bool(resp.get("ok")), "message_id": message_id}
|
||||
)
|
||||
|
||||
async def _read_commands(self) -> None:
|
||||
loop = asyncio.get_event_loop()
|
||||
reader = asyncio.StreamReader()
|
||||
protocol = asyncio.StreamReaderProtocol(reader)
|
||||
await loop.connect_read_pipe(lambda: protocol, sys.stdin)
|
||||
while not self._stop.is_set():
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
break
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
try:
|
||||
command = json.loads(stripped)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
asyncio.create_task(self.handle_command(command))
|
||||
self._stop.set()
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
updates = await self.poll_once()
|
||||
except Exception as exc: # noqa: BLE001 - keep polling through transient failures
|
||||
await self._log(f"poll loop error: {exc}")
|
||||
await asyncio.sleep(ERROR_BACKOFF_SECONDS)
|
||||
continue
|
||||
for update in updates:
|
||||
try:
|
||||
await self.process_update(update)
|
||||
except Exception as exc: # noqa: BLE001 - one bad update never stops the loop
|
||||
await self._log(f"update processing error: {exc}")
|
||||
|
||||
async def run(self) -> None:
|
||||
await self._emit({"type": "ready"})
|
||||
await self._log("Telegram worker started")
|
||||
reader_task = asyncio.create_task(self._read_commands())
|
||||
poll_task = asyncio.create_task(self._poll_loop())
|
||||
await self._stop.wait()
|
||||
poll_task.cancel()
|
||||
reader_task.cancel()
|
||||
await asyncio.gather(poll_task, reader_task, return_exceptions=True)
|
||||
|
||||
|
||||
async def _stdout_emitter() -> Callable[[dict[str, Any]], Awaitable[None]]:
|
||||
lock = asyncio.Lock()
|
||||
|
||||
async def emit(frame: dict[str, Any]) -> None:
|
||||
async with lock:
|
||||
sys.stdout.write(json.dumps(frame, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
return emit
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
|
||||
emit = await _stdout_emitter()
|
||||
if not token:
|
||||
await emit({"type": "log", "line": "TELEGRAM_BOT_TOKEN is not set; exiting"})
|
||||
return
|
||||
poll_timeout = int(os.environ.get("TELEGRAM_POLL_TIMEOUT", DEFAULT_POLL_TIMEOUT))
|
||||
image_cap = int(os.environ.get("TELEGRAM_IMAGE_MAX_BYTES", DEFAULT_IMAGE_MAX_BYTES))
|
||||
backend = HttpTelegramBackend(token)
|
||||
worker = TelegramWorker(
|
||||
backend, emit, poll_timeout=poll_timeout, image_max_bytes=image_cap
|
||||
)
|
||||
await worker.run()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
asyncio.run(_main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user