fix: correct "bugs" to "issues" in routing table and README references across multiple documentation files

This commit is contained in:
2026-06-14 07:48:10 +00:00
parent f79a427f32
commit f2b910ea75
162 changed files with 7011 additions and 1134 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
"post": "content",
"comment": "content",
"gist": "content",
"bug": "content",
"issue": "content",
"vote": "engagement",
"reaction": "engagement",
"bookmark": "engagement",
+11 -3
View File
@@ -4,9 +4,10 @@ import json
import logging
from typing import Any, Optional
from devplacepy.utils import strip_html
from devplacepy.utils import strip_html, generate_uid
from devplacepy.services.audit import store
from devplacepy.services.audit.categories import category_for
from devplacepy.services.background import background
logger = logging.getLogger(__name__)
@@ -160,6 +161,11 @@ def _coerce_scalar(value: Any) -> Optional[str]:
return str(value)
def _persist(row: dict, links: list[dict]) -> None:
audit_uid = store.insert_event(row)
store.insert_links(audit_uid, links)
def _write(
*,
event_key: str,
@@ -184,7 +190,10 @@ def _write(
resolved_via = via_agent
if resolved_via is None:
resolved_via = 1 if request_fields.get("devii") else 0
audit_uid = generate_uid()
row = {
"uid": audit_uid,
"created_at": store.now(),
"event_key": event_key,
"category": category or category_for(event_key),
"actor_kind": base_actor.get("actor_kind"),
@@ -213,8 +222,7 @@ def _write(
all_links.append(
actor(base_actor["actor_uid"], base_actor.get("actor_username"))
)
audit_uid = store.insert_event(row)
store.insert_links(audit_uid, all_links)
background.submit(_persist, row, all_links)
logger.debug(
"audit %s by %s/%s result=%s origin=%s",
event_key,
+103
View File
@@ -0,0 +1,103 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
QUEUE_MAXSIZE = 10000
DRAIN_BATCH = 64
class BackgroundQueue:
def __init__(self, maxsize: int = QUEUE_MAXSIZE) -> None:
self._queue: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
self._task: Optional[asyncio.Task] = None
self._submitted = 0
self._processed = 0
self._failed = 0
self._inline = 0
@property
def running(self) -> bool:
return self._task is not None and not self._task.done()
def submit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
self._submitted += 1
if not self.running:
self._execute(fn, args, kwargs, inline=True)
return
try:
self._queue.put_nowait((fn, args, kwargs))
except asyncio.QueueFull:
logger.warning("background queue full; running task inline")
self._execute(fn, args, kwargs, inline=True)
def _execute(self, fn: Callable[..., Any], args: tuple, kwargs: dict, inline: bool = False) -> None:
if inline:
self._inline += 1
try:
fn(*args, **kwargs)
self._processed += 1
except Exception as exc:
self._failed += 1
logger.warning("background task %s failed: %s", getattr(fn, "__name__", fn), exc)
async def start(self) -> None:
if self.running:
return
self._task = asyncio.create_task(self._drain())
logger.info("background task queue started")
async def stop(self) -> None:
task = self._task
self._task = None
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._flush_remaining()
logger.info(
"background task queue stopped processed=%s failed=%s inline=%s",
self._processed,
self._failed,
self._inline,
)
def _flush_remaining(self) -> None:
while True:
try:
fn, args, kwargs = self._queue.get_nowait()
except asyncio.QueueEmpty:
break
self._execute(fn, args, kwargs)
self._queue.task_done()
async def _drain(self) -> None:
while True:
fn, args, kwargs = await self._queue.get()
self._execute(fn, args, kwargs)
self._queue.task_done()
for _ in range(DRAIN_BATCH - 1):
try:
fn, args, kwargs = self._queue.get_nowait()
except asyncio.QueueEmpty:
break
self._execute(fn, args, kwargs)
self._queue.task_done()
def stats(self) -> dict:
return {
"running": self.running,
"depth": self._queue.qsize(),
"submitted": self._submitted,
"processed": self._processed,
"failed": self._failed,
"inline": self._inline,
}
background = BackgroundQueue()
+39 -46
View File
@@ -17,7 +17,6 @@ from devplacepy.services.bot.config import (
COMMENT_COOLDOWN_MIN_SECONDS,
FEED_TOPICS,
GIST_LANGUAGES,
HANDLE_SUFFIX_WORDS,
MAX_SIBLING_COMMENTS,
MAX_THREAD_REPLIES,
MENTION_REPLIES_PER_SESSION,
@@ -36,6 +35,7 @@ from devplacepy.services.bot.config import (
persona_article_score,
pick_category,
)
from devplacepy.services.bot.handles import make_handle
from devplacepy.services.bot.llm import LLMClient
from devplacepy.services.bot.news_fetcher import NewsFetcher
from devplacepy.services.bot.registry import ArticleRegistry
@@ -91,7 +91,7 @@ class DevPlaceBot:
self._session_mention_replies = 0
self._post_cache: list[tuple[str, str, str, str, str]] = []
self._project_cache: list[tuple[str, str]] = []
self._bug_cache: list[tuple[str, str]] = []
self._issue_cache: list[tuple[str, str]] = []
self._session_start_cost: float = 0.0
self._session_start_calls: int = 0
self._session_history: list[str] = []
@@ -214,10 +214,10 @@ class DevPlaceBot:
self.llm.generate_project_desc, t, self.state.persona
)
self._project_cache.append((t, desc))
elif kind == "bug":
elif kind == "issue":
topic = random.choice(["UI glitch", "Broken link", "Performance issue"])
bug = await asyncio.to_thread(self.llm.generate_bug, topic)
self._bug_cache.append(bug)
issue = await asyncio.to_thread(self.llm.generate_issue, topic)
self._issue_cache.append(issue)
except Exception as e:
logger.warning("Cache refill (%s) failed: %s", kind, e)
@@ -225,36 +225,33 @@ class DevPlaceBot:
self._log("Warming content cache (background)...")
self._post_cache = []
self._project_cache = []
self._bug_cache = []
self._issue_cache = []
self.news.fetch()
asyncio.create_task(self._refill_cache_async("post"))
asyncio.create_task(self._refill_cache_async("project"))
asyncio.create_task(self._refill_cache_async("bug"))
asyncio.create_task(self._refill_cache_async("issue"))
def _generate_handle(self) -> str:
first = re.sub(r"[^a-z]", "", self.faker.first_name().lower()) or "dev"
last = re.sub(r"[^a-z]", "", self.faker.last_name().lower()) or "coder"
styles = [
first,
f"{first}.{last}",
f"{first}{last}",
f"{first}_{last}",
f"{first[0]}{last}",
f"{first}{last[0]}",
f"{last}{first[0]}",
]
handle = random.choice(styles)
if random.random() < 0.18:
word = random.choice(HANDLE_SUFFIX_WORDS)
sep = random.choice(["", "_", ".", "-"])
handle = f"{handle}{sep}{word}"
handle = re.sub(r"[^a-z0-9._-]", "", handle)[:20]
return handle or "dev"
return make_handle(SEARCH_TERMS.get(self.state.persona, []))
def _next_handle(self) -> str:
while self.state.handle_candidates:
candidate = self.state.handle_candidates.pop(0)
if candidate:
return candidate
return self._generate_handle()
async def _ensure_auth(self) -> bool:
b = self.b
if not self.state.username:
self.state.username = self._generate_handle()
if not self.state.handle_candidates:
self.state.handle_candidates = (
await self._generate(
self.llm.generate_handle_candidates, self.state.persona
)
or []
)
self.state.username = self._next_handle()
self.state.email = self.faker.email()
self.state.password = self.faker.password(length=random.randint(12, 18))
self.state.started_at = datetime.now().isoformat()
@@ -299,14 +296,10 @@ class DevPlaceBot:
self._log(f"Registered as {self.state.username}")
return True
if attempt >= 2:
base = self._generate_handle()
word = random.choice(HANDLE_SUFFIX_WORDS)
sep = random.choice(["", "_", ".", "-"])
self.state.username = re.sub(
r"[^a-z0-9._-]", "", f"{base}{sep}{word}"
)[:20]
base = self._next_handle()
self.state.username = f"{base}{random.randint(10, 9999)}"[:20]
else:
self.state.username = self._generate_handle()
self.state.username = self._next_handle()
self.state.email = self.faker.email()
self._log(f"Signup failed, retrying as {self.state.username}")
return False
@@ -1794,15 +1787,15 @@ class DevPlaceBot:
return True
return False
async def _report_bug(self) -> bool:
async def _report_issue(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/bugs")
ok = await b.click("[data-modal='create-bug-modal']")
await b.goto(f"{self.base_url}/issues")
ok = await b.click("[data-modal='create-issue-modal']")
if not ok:
return False
await b._idle(0.5, 1.5)
if not await b.text("#create-bug-modal"):
if not await b.text("#create-issue-modal"):
return False
topic = random.choice(
@@ -1814,27 +1807,27 @@ class DevPlaceBot:
"Formatting error",
]
)
if self._bug_cache:
title, desc = self._bug_cache.pop(0)
asyncio.create_task(self._refill_cache_async("bug"))
if self._issue_cache:
title, desc = self._issue_cache.pop(0)
asyncio.create_task(self._refill_cache_async("issue"))
else:
generated = await self._generate(self.llm.generate_bug, topic)
generated = await self._generate(self.llm.generate_issue, topic)
if not generated:
self._log("Report bug: generation failed")
self._log("Report issue: generation failed")
return False
title, desc = generated
await b.fill("#bug-title", title[:200])
await b.fill("#issue-title", title[:200])
await b._idle(0.2, 0.5)
await b.fill("textarea[name='description']", desc[:2000])
await b._idle(0.5, 1.0)
ok = await b.click(
"button:has-text('Submit Report'), #create-bug-modal .btn-primary"
"button:has-text('Submit Report'), #create-issue-modal .btn-primary"
)
if ok:
self.state.bugs_filed += 1
self._action("BUG", f"{title[:50]} [{self.state.bugs_filed}]")
self.state.issues_filed += 1
self._action("ISSUE", f"{title[:50]} [{self.state.issues_filed}]")
await asyncio.sleep(2)
return True
return False
+137
View File
@@ -0,0 +1,137 @@
# retoor <retoor@molodetz.nl>
import random
import re
from typing import Optional
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
ALLCAPS_MAX_LEN = 8
PAD_NUMBER_FLOOR = 10
PAD_NUMBER_CEIL = 9999
LEET_MAP = {
"a": "4",
"e": "3",
"i": "1",
"o": "0",
"s": "5",
"t": "7",
"l": "1",
"g": "9",
"b": "8",
}
TECH_NOUNS = [
"byte", "bit", "nibble", "null", "void", "root", "kernel", "daemon",
"socket", "buffer", "cache", "stack", "heap", "pointer", "segfault",
"regex", "lambda", "vector", "packet", "proxy", "token", "hash", "cipher",
"vortex", "flux", "glitch", "pixel", "neon", "cyber", "quantum", "thread",
"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",
]
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",
]
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",
]
HANDLE_PREFIXES = ["the", "mr", "dr", "lord", "captain", "uncle", "sir", "prof", "agent"]
NUMBER_SUFFIXES = ["42", "1337", "404", "64", "99", "007", "23", "88", "256", "512", "101", "777"]
SEPARATORS = ["_", "-"]
def _join(rng: random.Random, parts: list[str]) -> str:
separator = rng.choice(SEPARATORS) if rng.random() < SEPARATOR_PROBABILITY else ""
return separator.join(parts)
def _seed_tokens(interests: list[str]) -> list[str]:
tokens: list[str] = []
for term in interests:
for piece in re.split(r"[^a-z0-9]+", term.lower()):
if len(piece) >= MIN_SEED_TOKEN_LEN:
tokens.append(piece)
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)]),
]
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]
return rng.choices(builders, weights=weights)[0]()
def apply_leet(text: str, rng: random.Random) -> str:
chars: list[str] = []
for char in text:
replacement = LEET_MAP.get(char.lower())
if replacement and rng.random() < LEET_CHAR_PROBABILITY:
chars.append(replacement)
else:
chars.append(char)
return "".join(chars)
def _apply_case(text: str, rng: random.Random) -> str:
roll = rng.random()
if roll < ALLCAPS_PROBABILITY and len(text) <= ALLCAPS_MAX_LEN:
return text.upper()
if roll < ALLCAPS_PROBABILITY + CAPITALIZE_PROBABILITY:
return text[:1].upper() + text[1:]
return text
def sanitize_handle(raw: str) -> str:
handle = re.sub(r"[^a-zA-Z0-9_-]", "", str(raw).strip())
handle = re.sub(r"[_-]{2,}", lambda match: match.group(0)[0], handle).strip("_-")
return handle[:MAX_HANDLE_LEN].rstrip("_-")
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:
core = apply_leet(core, generator)
if generator.random() < NUMBER_SUFFIX_PROBABILITY:
core = f"{core}{generator.choice(NUMBER_SUFFIXES)}"
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)
return handle or f"dev{generator.randint(PAD_NUMBER_FLOOR, PAD_NUMBER_CEIL)}"
+52 -21
View File
@@ -5,10 +5,11 @@ import logging
import random
import re
import time
import urllib.error
import urllib.request
from typing import Any
import httpx
from devplacepy import stealth
from devplacepy.services.bot.config import (
GIST_LANGUAGES,
GIST_MIN_LINES,
@@ -17,6 +18,9 @@ from devplacepy.services.bot.config import (
SEARCH_TERMS,
TRIVIAL_GIST_TERMS,
)
from devplacepy.services.bot.handles import MAX_HANDLE_LEN, sanitize_handle
HANDLE_CANDIDATE_TARGET = 8
logger = logging.getLogger(__name__)
@@ -61,19 +65,15 @@ class LLMClient:
json.dumps(system[:500]),
json.dumps(prompt[:500]),
)
data = json.dumps(payload).encode()
req = urllib.request.Request(
self.api_url,
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
},
)
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
with stealth.stealth_sync_client(timeout=30) as client:
resp = client.post(
self.api_url,
json=payload,
headers={"Authorization": f"Bearer {self.api_key}"},
)
raw = resp.content
logger.info("LLM <<< %s", raw[:2000].decode(errors="replace"))
result = json.loads(raw)
result = resp.json()
usage = result.get("usage", {})
in_tokens = usage.get("prompt_tokens", 0)
out_tokens = usage.get("completion_tokens", 0)
@@ -86,8 +86,7 @@ class LLMClient:
self.total_out_tokens += out_tokens
return result["choices"][0]["message"]["content"]
except (
urllib.error.HTTPError,
urllib.error.URLError,
httpx.HTTPError,
json.JSONDecodeError,
KeyError,
) as e:
@@ -126,7 +125,7 @@ class LLMClient:
"snippet",
"headline",
"gist",
"bug",
"issue",
)
result = (text or "").strip().strip('"').strip("'").strip()
changed = True
@@ -476,6 +475,38 @@ class LLMClient:
data = self._parse_json(self._raw_call(system, prompt, temperature=0.9))
return self._normalize_identity(data, archetype)
def generate_handle_candidates(self, archetype: str) -> list[str]:
interests = ", ".join(SEARCH_TERMS.get(archetype, []))
system = (
"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."
)
prompt = (
f"Archetype: {archetype}\n"
f"Their interests: {interests}\n"
"Create the handles JSON:"
)
try:
data = self._parse_json(
self._raw_call(system, prompt, temperature=1.0)
)
except (ValueError, json.JSONDecodeError) as exc:
logger.warning("Handle candidate generation failed: %s", exc)
return []
handles: list[str] = []
for raw in data.get("handles", []):
handle = sanitize_handle(raw)
if handle and handle.lower() not in {h.lower() for h in handles}:
handles.append(handle)
return handles
@staticmethod
def _identity_card(identity: dict) -> str:
lines = [
@@ -586,13 +617,13 @@ class LLMClient:
break
return result
def generate_bug(self, topic: str) -> tuple[str, str]:
def generate_issue(self, topic: str) -> tuple[str, str]:
text = self._call(
"Write a bug report. One line title. Then 2-3 sentences describing what happened and what should have happened. Like a real dev reporting a bug. No em dashes.",
f"Bug: {topic}",
"Write an issue report. One line title. Then 2-3 sentences describing what happened and what should have happened. Like a real dev reporting an issue. No em dashes.",
f"Issue: {topic}",
)
lines = text.strip().split("\n")
title = lines[0].strip().lstrip("#").strip()[:120] or f"Bug report: {topic}"
title = lines[0].strip().lstrip("#").strip()[:120] or f"Issue report: {topic}"
desc = " ".join(
line.lstrip("-* ").strip() for line in lines[1:] if line.strip()
)
+5 -7
View File
@@ -1,9 +1,9 @@
# retoor <retoor@molodetz.nl>
import json
import logging
import time
import urllib.request
from devplacepy import stealth
logger = logging.getLogger(__name__)
@@ -23,11 +23,9 @@ class NewsFetcher:
if fresh:
return self.articles
try:
req = urllib.request.Request(
self.news_api, headers={"User-Agent": "dpbot/1.0"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
articles = json.loads(resp.read()).get("articles", [])
with stealth.stealth_sync_client(timeout=10) as client:
resp = client.get(self.news_api)
articles = resp.json().get("articles", [])
if articles:
self.articles = articles
self.fetched_at = time.monotonic()
+2 -1
View File
@@ -15,6 +15,7 @@ class BotState:
password: str = ""
uid: str = ""
persona: str = ""
handle_candidates: list[str] = field(default_factory=list)
profile_filled: bool = False
user_agent: str = ""
viewport: dict = field(default_factory=dict)
@@ -27,7 +28,7 @@ class BotState:
comments_posted: int = 0
quality_rejections: int = 0
votes_cast: int = 0
bugs_filed: int = 0
issues_filed: int = 0
projects_created: int = 0
gists_created: int = 0
profiles_viewed: int = 0
+2 -4
View File
@@ -6,9 +6,7 @@ import re
import socket
from pathlib import Path
import httpx
from devplacepy import config, project_files
from devplacepy import config, project_files, stealth
from devplacepy.services.containers import store
from devplacepy.services.containers.backend.base import (
WORKSPACE_MOUNT,
@@ -369,7 +367,7 @@ def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
try:
with httpx.Client(timeout=timeout) as client:
with stealth.stealth_sync_client(timeout=timeout) as client:
response = client.get(f"http://{host}:{port}/")
return f"HTTP {response.status_code}"
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
+2 -3
View File
@@ -8,8 +8,7 @@ import math
import re
from dataclasses import dataclass
import httpx
from devplacepy import stealth
from devplacepy.config import INTERNAL_EMBED_MODEL, INTERNAL_EMBED_URL
logger = logging.getLogger(__name__)
@@ -56,7 +55,7 @@ async def embed_texts(
}
payload = {"model": INTERNAL_EMBED_MODEL, "input": texts}
try:
async with httpx.AsyncClient(timeout=EMBED_TIMEOUT_SECONDS) as client:
async with stealth.stealth_async_client(timeout=EMBED_TIMEOUT_SECONDS) as client:
response = await client.post(gateway_url, json=payload, headers=headers)
if response.status_code >= 400:
raise RuntimeError(f"embed gateway returned {response.status_code}")
+2 -3
View File
@@ -4,8 +4,7 @@ from __future__ import annotations
import logging
import httpx
from devplacepy import stealth
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
logger = logging.getLogger(__name__)
@@ -33,7 +32,7 @@ async def complete_chat(
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=CHAT_TIMEOUT_SECONDS) as client:
async with stealth.stealth_async_client(timeout=CHAT_TIMEOUT_SECONDS) as client:
response = await client.post(gateway_url, json=payload, headers=headers)
if response.status_code >= 400:
raise RuntimeError(f"chat gateway returned {response.status_code}")
+29 -29
View File
@@ -885,10 +885,10 @@ ACTIONS: tuple[Action, ...] = (
requires_auth=False,
),
Action(
name="list_bugs",
name="list_issues",
method="GET",
path="/bugs",
summary="List bug tickets from the Gitea tracker",
path="/issues",
summary="List issue tickets from the Gitea tracker",
requires_auth=False,
params=(
query("state", "Filter by state: open, closed, or all."),
@@ -896,58 +896,58 @@ ACTIONS: tuple[Action, ...] = (
),
),
Action(
name="create_bug",
name="create_issue",
method="POST",
path="/bugs/create",
summary="Report a bug. It is rewritten by AI and filed on the tracker.",
path="/issues/create",
summary="Report an issue. It is rewritten by AI and filed on the tracker.",
description=(
"Queues a background bug creation job and returns {uid, status_url}. Poll the "
"status_url with bug_job_status until status is 'done', then show the user the "
"bug_url pointing at the filed ticket."
"Queues a background issue creation job and returns {uid, status_url}. Poll the "
"status_url with issue_job_status until status is 'done', then show the user the "
"issue_url pointing at the filed ticket."
),
params=(
body("title", "Bug title.", required=True),
body("description", "Bug description.", required=True),
body("title", "Issue title.", required=True),
body("description", "Issue description.", required=True),
),
),
Action(
name="bug_job_status",
name="issue_job_status",
method="GET",
path="/bugs/jobs/{uid}",
summary="Poll a bug creation job and obtain the filed bug ticket URL once finished",
path="/issues/jobs/{uid}",
summary="Poll an issue creation job and obtain the filed issue ticket URL once finished",
description=(
"Returns the job status. When status is 'done', bug_url and number are populated; "
"Returns the job status. When status is 'done', issue_url and number are populated; "
"while 'pending' or 'running', poll again shortly."
),
params=(path("uid", "Bug job uid returned by create_bug."),),
params=(path("uid", "Issue job uid returned by create_issue."),),
requires_auth=False,
),
Action(
name="view_bug",
name="view_issue",
method="GET",
path="/bugs/{number}",
summary="View a bug ticket and its developer updates",
path="/issues/{number}",
summary="View an issue ticket and its developer updates",
requires_auth=False,
params=(path("number", "The bug ticket number from a /bugs listing."),),
params=(path("number", "The issue ticket number from a /issues listing."),),
),
Action(
name="comment_bug",
name="comment_issue",
method="POST",
path="/bugs/{number}/comment",
summary="Post a comment on a bug ticket, attributed to the current user",
path="/issues/{number}/comment",
summary="Post a comment on an issue ticket, attributed to the current user",
params=(
path("number", "The bug ticket number."),
path("number", "The issue ticket number."),
body("body", "Comment text.", required=True),
),
),
Action(
name="set_bug_status",
name="set_issue_status",
method="POST",
path="/bugs/{number}/status",
summary="Change a bug ticket status (admin only)",
path="/issues/{number}/status",
summary="Change an issue ticket status (admin only)",
requires_admin=True,
params=(
path("number", "The bug ticket number."),
path("number", "The issue ticket number."),
body("status", "New status: open or closed.", required=True),
),
),
@@ -1056,7 +1056,7 @@ ACTIONS: tuple[Action, ...] = (
"Fetches the file at the given URL on the server (size-capped, SSRF-guarded) and stores it "
"as an attachment exactly like an upload, returning {uid, url, mime_type, ...}. Pass the "
"returned uid in attachment_uids when you create_post, create_project, create_gist, "
"create_comment, create_bug, or send_message to attach it. The file type is taken from the "
"create_comment, create_issue, or send_message to attach it. The file type is taken from the "
"URL or its Content-Type; supply 'filename' (with an allowed extension) when the URL has no "
"usable name. Use this to attach an image or file straight from the internet."
),
@@ -18,7 +18,7 @@ def arg(
TYPES = (
"One of: comment, reply, mention, vote, follow, message, badge, level, bug."
"One of: comment, reply, mention, vote, follow, message, badge, level, issue."
)
NOTIFICATION_ACTIONS: tuple[Action, ...] = (
+2 -2
View File
@@ -160,10 +160,10 @@ SYSTEM_PROMPT = (
"loopback addresses are refused.\n\n"
"ATTACHING FILES FROM THE INTERNET\n"
"To attach an image or file that lives at a public URL to something you create (a post, project, "
"gist, comment, bug report, or direct message), call attach_url with that URL. It downloads the "
"gist, comment, issue report, or direct message), call attach_url with that URL. It downloads the "
"file on the server and stores it as a real attachment, returning a uid. Then pass that uid in the "
"attachment_uids field when you call create_post, create_project, create_gist, create_comment, "
"create_bug, or send_message, and the file is attached. Collect several uids to attach more than "
"create_issue, or send_message, and the file is attached. Collect several uids to attach more than "
"one. Do not paste the raw URL into the body when the user wants it attached - use attach_url so it "
"becomes a proper hosted attachment with a thumbnail. The file type comes from the URL or its "
"Content-Type; if a URL has no clear name, pass a filename with an allowed extension.\n\n"
@@ -13,6 +13,7 @@ from urllib.parse import urlparse
import httpx
from devplacepy import stealth
from devplacepy.net_guard import effective_address, is_blocked_address
from ..config import Settings
from ..errors import NetworkError, ToolInputError, UpstreamError
@@ -240,7 +241,7 @@ class FetchController:
elif content is not None:
request_kwargs["content"] = content
try:
async with httpx.AsyncClient(
async with stealth.stealth_async_client(
headers=merged_headers,
follow_redirects=True,
timeout=self._settings.fetch_timeout_seconds,
+2 -1
View File
@@ -8,6 +8,7 @@ from typing import Any
import httpx
from devplacepy import stealth
from .errors import AuthRequiredError, NetworkError, ToolInputError, UpstreamError
logger = logging.getLogger("devii.http")
@@ -28,7 +29,7 @@ class PlatformClient:
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["X-API-Key"] = api_key
self._client = httpx.AsyncClient(
self._client = stealth.stealth_async_client(
base_url=base_url,
timeout=timeout_seconds,
follow_redirects=True,
+2 -1
View File
@@ -7,6 +7,7 @@ from typing import Any
import httpx
from devplacepy import stealth
from .config import Settings
from .cost import record_usage
from .errors import LLMError
@@ -17,7 +18,7 @@ logger = logging.getLogger("devii.llm")
class LLMClient:
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._client = httpx.AsyncClient(
self._client = stealth.stealth_async_client(
timeout=settings.timeout_seconds,
headers={
"Authorization": f"Bearer {settings.ai_key}",
@@ -8,6 +8,7 @@ from typing import Any
import httpx
from devplacepy import stealth
from ..config import Settings
from ..errors import NetworkError, ToolInputError, UpstreamError
@@ -47,7 +48,7 @@ class RsearchController:
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
timeout = httpx.Timeout(self._settings.rsearch_timeout_seconds, connect=30.0)
try:
async with httpx.AsyncClient(
async with stealth.stealth_async_client(
base_url=self._settings.rsearch_url,
headers=headers,
follow_redirects=True,
+1
View File
@@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>
+28
View File
@@ -0,0 +1,28 @@
# retoor <retoor@molodetz.nl>
import hashlib
import logging
from typing import Optional
from devplacepy.avatar import generate_avatar_svg
logger = logging.getLogger(__name__)
def background_color(username: Optional[str]) -> str:
seed = username or "?"
return hashlib.md5(seed.encode("utf-8")).hexdigest()[:6]
def avatar_payload(username: Optional[str]) -> dict:
seed = username or "anonymous"
return {"b": background_color(seed), "i": f"u/{seed}.png"}
def render_png(username: str, size: int = 128) -> bytes:
import cairosvg
svg = generate_avatar_svg(username)
return cairosvg.svg2png(
bytestring=svg.encode("utf-8"), output_width=size, output_height=size
)
+101
View File
@@ -0,0 +1,101 @@
# retoor <retoor@molodetz.nl>
from typing import Optional
from devplacepy.attachments import get_attachments_batch
from devplacepy.database import (
get_table,
get_users_by_uids,
get_user_votes,
get_vote_counts,
get_comment_counts_by_post_uids,
get_user_stars,
text_search_clause,
)
from devplacepy.services.devrant.serializers import serialize_rant, serialize_comment
SORT_ORDER = {
"recent": ["-created_at"],
"top": ["-stars", "-created_at"],
"algo": ["-stars", "-created_at"],
}
def _score_maps(user_uids: list) -> dict:
return {uid: get_user_stars(uid) for uid in set(user_uids)}
def build_rant_list(posts: list, viewer: Optional[dict]) -> list:
post_uids = [post["uid"] for post in posts]
user_uids = [post["user_uid"] for post in posts]
authors = get_users_by_uids(list(set(user_uids)))
user_scores = _score_maps(user_uids)
my_votes = get_user_votes(viewer["uid"], post_uids) if viewer else {}
comment_counts = get_comment_counts_by_post_uids(post_uids)
attachments = get_attachments_batch("post", post_uids)
return [
serialize_rant(
post,
authors=authors,
comment_counts=comment_counts,
user_scores=user_scores,
my_votes=my_votes,
attachments=attachments,
viewer=viewer,
)
for post in posts
]
def list_rants(sort: str, limit: int, skip: int, viewer: Optional[dict]) -> list:
order = SORT_ORDER.get(sort, SORT_ORDER["recent"])
posts = list(
get_table("posts").find(
deleted_at=None, order_by=order, _limit=limit, _offset=skip
)
)
return build_rant_list(posts, viewer)
def search_rants(term: str, viewer: Optional[dict], limit: int = 50) -> list:
posts_table = get_table("posts")
clause = text_search_clause(posts_table, term, ("title", "content"))
if clause is None:
return []
posts = list(
posts_table.find(
clause, deleted_at=None, order_by=["-created_at"], _limit=limit
)
)
return build_rant_list(posts, viewer)
def load_rant_detail(post: dict, viewer: Optional[dict]) -> dict:
rant = build_rant_list([post], viewer)[0]
comments = list(
get_table("comments").find(
target_type="post",
target_uid=post["uid"],
deleted_at=None,
order_by=["created_at"],
)
)
comment_uids = [comment["uid"] for comment in comments]
user_uids = [comment["user_uid"] for comment in comments]
authors = get_users_by_uids(list(set(user_uids)))
user_scores = _score_maps(user_uids)
ups, downs = get_vote_counts(comment_uids)
score_map = {uid: ups.get(uid, 0) - downs.get(uid, 0) for uid in comment_uids}
my_votes = get_user_votes(viewer["uid"], comment_uids) if viewer else {}
serialized = [
serialize_comment(
comment,
int(post["id"]),
authors=authors,
user_scores=user_scores,
my_votes=my_votes,
score_map=score_map,
)
for comment in comments
]
return {"rant": rant, "comments": serialized}
+50
View File
@@ -0,0 +1,50 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from typing import Optional
from devplacepy.database import get_table
def as_int(value: object, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def to_unix(iso_value: Optional[str]) -> int:
if not iso_value:
return 0
try:
moment = datetime.fromisoformat(iso_value)
except (ValueError, TypeError):
return 0
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return int(moment.timestamp())
def now_unix() -> int:
return int(datetime.now(timezone.utc).timestamp())
def post_by_id(rant_id: object) -> Optional[dict]:
identifier = as_int(rant_id)
if not identifier:
return None
return get_table("posts").find_one(id=identifier, deleted_at=None)
def comment_by_id(comment_id: object) -> Optional[dict]:
identifier = as_int(comment_id)
if not identifier:
return None
return get_table("comments").find_one(id=identifier, deleted_at=None)
def user_by_id(user_id: object) -> Optional[dict]:
identifier = as_int(user_id)
if not identifier:
return None
return get_table("users").find_one(id=identifier)
@@ -0,0 +1,84 @@
# retoor <retoor@molodetz.nl>
from typing import Optional
from devplacepy.database import get_table, get_users_by_uids
from devplacepy.services.devrant.ids import to_unix, now_unix
DEVRANT_TYPE_MAP = {
"comment": "comment_discuss",
"reply": "comment_discuss",
"mention": "comment_mention",
"vote": "rant_vote",
"follow": "rant_sub",
}
UNREAD_BUCKET = {
"rant_vote": "upvotes",
"comment_mention": "mentions",
"comment_discuss": "comments",
"rant_sub": "subs",
}
FEED_LIMIT = 50
def build_notif_feed(user: dict) -> dict:
notifications = list(
get_table("notifications").find(
user_uid=user["uid"], order_by=["-created_at"], _limit=FEED_LIMIT
)
)
actor_uids = list(
{note.get("related_uid") for note in notifications if note.get("related_uid")}
)
actors = get_users_by_uids(actor_uids)
username_map: dict = {}
items = []
unread = {
"all": 0,
"upvotes": 0,
"mentions": 0,
"comments": 0,
"subs": 0,
"total": 0,
}
for note in notifications:
dr_type = DEVRANT_TYPE_MAP.get(note.get("type"))
if not dr_type:
continue
actor = actors.get(note.get("related_uid")) or {}
actor_id = int(actor.get("id") or 0)
if actor_id:
username_map[str(actor_id)] = actor.get("username") or ""
is_read = 1 if note.get("read") else 0
items.append(
{
"type": dr_type,
"rant_id": 0,
"comment_id": 0,
"created_time": to_unix(note.get("created_at")),
"read": is_read,
"uid": actor_id,
"username": actor.get("username") or "",
}
)
if not is_read:
bucket = UNREAD_BUCKET.get(dr_type)
if bucket:
unread[bucket] += 1
unread["all"] += 1
unread["total"] += 1
return {
"items": items,
"check_time": now_unix(),
"username_map": username_map,
"unread": unread,
"num_unread": unread["total"],
}
def clear_notifications(user: dict) -> None:
notifications = get_table("notifications")
for note in notifications.find(user_uid=user["uid"], read=False):
notifications.update({"id": note["id"], "read": True}, ["id"])
+34
View File
@@ -0,0 +1,34 @@
# retoor <retoor@molodetz.nl>
import json
import logging
from typing import Any
from fastapi import Request
logger = logging.getLogger(__name__)
BODY_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
async def merge_params(request: Request) -> dict:
params: dict[str, Any] = dict(request.query_params)
if request.method not in BODY_METHODS:
return params
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
try:
body = await request.json()
except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
body = None
if isinstance(body, dict):
params.update(body)
return params
try:
form = await request.form()
except (ValueError, RuntimeError) as error:
logger.debug("devrant form parse failed: %s", error)
return params
for key in form:
params[key] = form[key]
return params
+109
View File
@@ -0,0 +1,109 @@
# retoor <retoor@molodetz.nl>
from typing import Optional
from devplacepy.database import (
get_table,
get_users_by_uids,
get_user_votes,
get_vote_counts,
get_user_stars,
)
from devplacepy.services.devrant.avatar import avatar_payload
from devplacepy.services.devrant.feed import build_rant_list
from devplacepy.services.devrant.ids import to_unix
from devplacepy.services.devrant.serializers import serialize_comment
PROFILE_RANT_LIMIT = 50
PROFILE_COMMENT_LIMIT = 50
SKILLS_MAX_WORDS = 8
def skills_from_bio(bio: Optional[str]) -> str:
if not bio:
return ""
words = bio.replace("\n", " ").split()
return " ".join(words[:SKILLS_MAX_WORDS])
def _user_rants(user: dict, viewer: Optional[dict]) -> list:
posts = list(
get_table("posts").find(
user_uid=user["uid"],
deleted_at=None,
order_by=["-created_at"],
_limit=PROFILE_RANT_LIMIT,
)
)
return build_rant_list(posts, viewer)
def _user_comments(user: dict, viewer: Optional[dict]) -> list:
comments = list(
get_table("comments").find(
user_uid=user["uid"],
target_type="post",
deleted_at=None,
order_by=["-created_at"],
_limit=PROFILE_COMMENT_LIMIT,
)
)
if not comments:
return []
target_uids = list({comment["target_uid"] for comment in comments})
posts_table = get_table("posts")
rant_ids = {}
for target_uid in target_uids:
post = posts_table.find_one(uid=target_uid)
if post:
rant_ids[target_uid] = int(post["id"])
comment_uids = [comment["uid"] for comment in comments]
authors = get_users_by_uids([user["uid"]])
user_scores = {user["uid"]: get_user_stars(user["uid"])}
ups, downs = get_vote_counts(comment_uids)
score_map = {uid: ups.get(uid, 0) - downs.get(uid, 0) for uid in comment_uids}
my_votes = get_user_votes(viewer["uid"], comment_uids) if viewer else {}
return [
serialize_comment(
comment,
rant_ids.get(comment["target_uid"], 0),
authors=authors,
user_scores=user_scores,
my_votes=my_votes,
score_map=score_map,
)
for comment in comments
]
def build_profile(user: dict, viewer: Optional[dict]) -> dict:
bio = user.get("bio") or ""
rants = _user_rants(user, viewer)
comments = _user_comments(user, viewer)
return {
"username": user.get("username") or "",
"score": get_user_stars(user["uid"]),
"about": bio,
"location": user.get("location") or "",
"created_time": to_unix(user.get("created_at")),
"skills": skills_from_bio(bio),
"github": user.get("git_link") or "",
"website": user.get("website") or "",
"avatar": avatar_payload(user.get("username")),
"content": {
"content": {
"rants": rants,
"upvoted": [],
"comments": comments,
"favorites": [],
"viewed": [],
},
"counts": {
"rants": len(rants),
"upvoted": 0,
"comments": len(comments),
"favorites": 0,
"collabs": 0,
},
},
}
+103
View File
@@ -0,0 +1,103 @@
# retoor <retoor@molodetz.nl>
import json
from typing import Optional
from devplacepy.services.devrant.avatar import avatar_payload
from devplacepy.services.devrant.ids import to_unix
def decode_tags(post: dict) -> list:
raw = post.get("tags")
if raw:
try:
value = json.loads(raw)
if isinstance(value, list):
return [str(tag) for tag in value]
except (ValueError, TypeError):
pass
topic = post.get("topic")
return [topic] if topic else []
def encode_tags(tags: list) -> str:
cleaned = [str(tag).strip() for tag in tags if str(tag).strip()]
return json.dumps(cleaned)
def rant_text(post: dict) -> str:
title = (post.get("title") or "").strip()
content = post.get("content") or ""
if title:
return f"{title}\n\n{content}"
return content
def _image_url(post: dict, attachments: Optional[list]) -> str:
if post.get("image"):
return f"/static/uploads/{post['image']}"
for attachment in attachments or []:
if attachment.get("is_image"):
return attachment.get("url") or ""
return ""
def serialize_rant(
post: dict,
*,
authors: dict,
comment_counts: dict,
user_scores: dict,
my_votes: dict,
attachments: Optional[dict] = None,
viewer: Optional[dict] = None,
) -> dict:
author = authors.get(post["user_uid"]) or {}
uid = post["uid"]
username = author.get("username") or ""
return {
"id": int(post["id"]),
"text": rant_text(post),
"score": int(post.get("stars") or 0),
"created_time": to_unix(post.get("created_at")),
"attached_image": _image_url(post, (attachments or {}).get(uid)),
"num_comments": int(comment_counts.get(uid, 0)),
"tags": decode_tags(post),
"vote_state": int(my_votes.get(uid, 0)),
"edited": bool(post.get("updated_at")),
"link": f"rants/{post['id']}/{post.get('slug') or ''}",
"rt": 1,
"rc": 0,
"user_id": int(author.get("id") or 0),
"user_username": username,
"user_score": int(user_scores.get(post["user_uid"], 0)),
"user_avatar": avatar_payload(username),
"user_avatar_lg": avatar_payload(username),
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
}
def serialize_comment(
comment: dict,
rant_id: int,
*,
authors: dict,
user_scores: dict,
my_votes: dict,
score_map: dict,
) -> dict:
author = authors.get(comment["user_uid"]) or {}
uid = comment["uid"]
username = author.get("username") or ""
return {
"id": int(comment["id"]),
"rant_id": int(rant_id),
"body": comment.get("content") or "",
"score": int(score_map.get(uid, 0)),
"created_time": to_unix(comment.get("created_at")),
"vote_state": int(my_votes.get(uid, 0)),
"user_id": int(author.get("id") or 0),
"user_username": username,
"user_score": int(user_scores.get(comment["user_uid"], 0)),
"user_avatar": avatar_payload(username),
}
+67
View File
@@ -0,0 +1,67 @@
# retoor <retoor@molodetz.nl>
import secrets
from datetime import datetime, timezone
from typing import Optional
from devplacepy.config import SECONDS_PER_DAY
from devplacepy.database import get_table, get_int_setting
from devplacepy.utils import generate_uid
from devplacepy.services.devrant.ids import as_int, now_unix
TOKEN_KEY_BYTES = 20
def issue_token(user: dict) -> dict:
tokens = get_table("devrant_tokens")
key = secrets.token_hex(TOKEN_KEY_BYTES)
max_age = max(1, get_int_setting("session_max_age_days", 7)) * SECONDS_PER_DAY
moment = datetime.now(timezone.utc)
expire_time = int(moment.timestamp()) + max_age
user_id = as_int(user.get("id"))
token_id = tokens.insert(
{
"uid": generate_uid(),
"key": key,
"user_uid": user["uid"],
"user_id": user_id,
"expire_time": expire_time,
"created_at": moment.isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return {
"id": int(token_id),
"key": key,
"expire_time": expire_time,
"user_id": user_id,
}
def resolve_user(params: dict) -> Optional[dict]:
token_id = as_int(params.get("token_id"))
token_key = (params.get("token_key") or "").strip()
user_id = as_int(params.get("user_id"))
if not token_id or not token_key:
return None
token = get_table("devrant_tokens").find_one(id=token_id, deleted_at=None)
if not token or token.get("key") != token_key:
return None
if user_id and as_int(token.get("user_id")) != user_id:
return None
if as_int(token.get("expire_time")) < now_unix():
return None
user = get_table("users").find_one(uid=token.get("user_uid"))
if not user or not user.get("is_active", True):
return None
return user
def revoke_all(user_uid: str) -> None:
tokens = get_table("devrant_tokens")
stamp = datetime.now(timezone.utc).isoformat()
for token in tokens.find(user_uid=user_uid, deleted_at=None):
tokens.update(
{"id": token["id"], "deleted_at": stamp, "deleted_by": user_uid}, ["id"]
)
+2 -1
View File
@@ -4,6 +4,7 @@ import logging
import httpx
from devplacepy import stealth
from devplacepy.services.gitea.config import (
HTTP_TIMEOUT_SECONDS,
GiteaConfig,
@@ -45,7 +46,7 @@ class GiteaClient:
self._require_config()
logger.debug("Gitea %s %s", method, url)
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
async with stealth.stealth_async_client(timeout=HTTP_TIMEOUT_SECONDS) as client:
response = await client.request(
method, url, headers=self._headers(), **kwargs
)
+7 -7
View File
@@ -57,7 +57,7 @@ CONFIG_FIELDS: list[ConfigField] = [
"Repository name",
type="str",
default=REPO_DEFAULT,
help="Repository whose issues back the bug tracker.",
help="Repository whose issues back the issue tracker.",
group="Gitea",
),
ConfigField(
@@ -70,7 +70,7 @@ CONFIG_FIELDS: list[ConfigField] = [
group="Gitea",
),
ConfigField(
"bug_ai_enhance",
"issue_ai_enhance",
"Improve tickets with AI",
type="bool",
default=True,
@@ -78,7 +78,7 @@ CONFIG_FIELDS: list[ConfigField] = [
group="AI",
),
ConfigField(
"bug_ai_model",
"issue_ai_model",
"AI model",
type="str",
default=INTERNAL_MODEL,
@@ -86,7 +86,7 @@ CONFIG_FIELDS: list[ConfigField] = [
group="AI",
),
ConfigField(
"bug_ai_key",
"issue_ai_key",
"AI API key",
type="password",
default="",
@@ -98,14 +98,14 @@ CONFIG_FIELDS: list[ConfigField] = [
def gitea_config() -> GiteaConfig:
ai_key = get_setting("bug_ai_key", "") or internal_gateway_key()
ai_key = get_setting("issue_ai_key", "") or internal_gateway_key()
return GiteaConfig(
base_url=get_setting("gitea_base_url", BASE_URL_DEFAULT),
owner=get_setting("gitea_owner", OWNER_DEFAULT),
repo=get_setting("gitea_repo", REPO_DEFAULT),
token=get_setting("gitea_token", ""),
ai_enhance=get_setting("bug_ai_enhance", "1") == "1",
ai_model=get_setting("bug_ai_model", INTERNAL_MODEL),
ai_enhance=get_setting("issue_ai_enhance", "1") == "1",
ai_model=get_setting("issue_ai_model", INTERNAL_MODEL),
ai_key=ai_key,
)
+6 -5
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass
import httpx
from devplacepy import stealth
from devplacepy.config import INTERNAL_GATEWAY_URL
from devplacepy.services.gitea.config import HTTP_TIMEOUT_SECONDS, GiteaConfig
@@ -18,7 +19,7 @@ BODY_MAX = 60000
SYSTEM_PROMPT = (
"You are a senior triage engineer for DevPlace, a social network for software "
"developers. You rewrite raw user bug reports into clear, consistent, high-quality "
"developers. You rewrite raw user issue reports into clear, consistent, high-quality "
"issue tickets for a Gitea tracker. Preserve every concrete fact the reporter gave; "
"never invent versions, stack traces, or steps that were not provided. Keep a calm, "
"technical, professional tone. Do not add greetings or sign-offs.\n\n"
@@ -91,7 +92,7 @@ async def enhance_ticket(
headers["Authorization"] = f"Bearer {config.ai_key}"
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
async with stealth.stealth_async_client(timeout=HTTP_TIMEOUT_SECONDS) as client:
response = await client.post(
INTERNAL_GATEWAY_URL, json=payload, headers=headers
)
@@ -103,13 +104,13 @@ async def enhance_ticket(
.get("content", "")
)
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
logger.warning("Bug ticket enhancement failed, using fallback: %s", exc)
logger.warning("Issue ticket enhancement failed, using fallback: %s", exc)
return _fallback(title, description)
parsed = _parse(content)
if not parsed:
logger.warning("Bug ticket enhancement returned unparseable content")
logger.warning("Issue ticket enhancement returned unparseable content")
return _fallback(title, description)
new_title, new_body = parsed
logger.info("Enhanced bug ticket '%s' -> '%s'", title[:60], new_title[:60])
logger.info("Enhanced issue ticket '%s' -> '%s'", title[:60], new_title[:60])
return EnhancedTicket(title=new_title, body=new_body, enhanced=True)
+15 -15
View File
@@ -11,20 +11,20 @@ from devplacepy.utils import create_notification
logger = logging.getLogger(__name__)
class BugTrackerService(BaseService):
interval_key = "bug_tracker_interval"
class IssueTrackerService(BaseService):
interval_key = "issue_tracker_interval"
min_interval = 30
default_enabled = False
title = "Bug Tracker"
title = "Issue Tracker"
description = (
"Holds the Gitea connection and AI settings for the bug tracker and polls every "
"Holds the Gitea connection and AI settings for the issue tracker and polls every "
"tracked issue for developer replies and status changes, notifying the original "
"reporter when their ticket is updated."
)
config_fields = CONFIG_FIELDS
def __init__(self):
super().__init__(name="bug_tracker", interval_seconds=120)
super().__init__(name="issue_tracker", interval_seconds=120)
async def run_once(self) -> None:
config = gitea_config()
@@ -69,24 +69,24 @@ class BugTrackerService(BaseService):
if external:
create_notification(
author_uid,
"bug",
f"New developer reply on your bug #{number}",
"issue",
f"New developer reply on your issue #{number}",
str(number),
f"/bugs/{number}",
f"/issues/{number}",
)
self._audit("bug.sync.reply", author_uid, number)
self._audit("issue.sync.reply", author_uid, number)
notified += 1
if state != last_status:
verb = "closed" if state == "closed" else "reopened"
create_notification(
author_uid,
"bug",
f"Your bug #{number} was {verb}",
"issue",
f"Your issue #{number} was {verb}",
str(number),
f"/bugs/{number}",
f"/issues/{number}",
)
self._audit("bug.sync.status", author_uid, number, {"state": state})
self._audit("issue.sync.status", author_uid, number, {"state": state})
notified += 1
store.update_ticket_cache(number, state, comment_count)
@@ -100,9 +100,9 @@ class BugTrackerService(BaseService):
audit.record_system(
event,
actor_kind="service",
target_type="bug",
target_type="issue",
target_uid=str(number),
metadata={"number": number, **(metadata or {})},
summary=f"bug #{number} update detected",
summary=f"issue #{number} update detected",
links=[audit.recipient(author_uid)],
)
+3 -3
View File
@@ -8,8 +8,8 @@ from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
TICKETS = "bug_tickets"
COMMENT_AUTHORS = "bug_comment_authors"
TICKETS = "issue_tickets"
COMMENT_AUTHORS = "issue_comment_authors"
def _now() -> str:
@@ -43,7 +43,7 @@ def record_ticket(
"deleted_by": None,
}
)
logger.info("Recorded bug ticket #%s by %s", number, author_uid)
logger.info("Recorded issue ticket #%s by %s", number, author_uid)
return uid
+31 -6
View File
@@ -10,8 +10,11 @@ from typing import Awaitable, Callable
import httpx
from devplacepy import stealth
from devplacepy.net_guard import BlockedAddressError, guard_public_url, guarded_async_client
from .pdf import MAX_PDF_BYTES, extract_pdf_text, is_pdf
logger = logging.getLogger(__name__)
RSEARCH_URL = "https://rsearch.app.molodetz.nl"
@@ -69,7 +72,7 @@ async def search_queries(queries: list[str]) -> list[dict]:
seen: set[str] = set()
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
timeout = httpx.Timeout(RSEARCH_TIMEOUT_SECONDS, connect=30.0)
async with httpx.AsyncClient(
async with stealth.stealth_async_client(
base_url=RSEARCH_URL, headers=headers, follow_redirects=True, timeout=timeout
) as client:
for query in queries:
@@ -130,19 +133,41 @@ async def fetch_page(url: str, depth: int) -> CrawledPage | None:
text = ""
status = 0
source = "httpx"
content_type = ""
encoding = "utf-8"
raw_bytes = b""
try:
async with guarded_async_client(
follow_redirects=True,
timeout=FETCH_TIMEOUT_SECONDS,
headers={"User-Agent": USER_AGENT},
) as client:
response = await client.get(url)
await guard_public_url(str(response.url))
status = response.status_code
raw = response.text[:MAX_FETCH_BYTES]
title, text = _strip_html(raw)
async with client.stream("GET", url) as response:
await guard_public_url(str(response.url))
status = response.status_code
content_type = response.headers.get("content-type", "").lower()
encoding = response.encoding or "utf-8"
buffer = bytearray()
async for chunk in response.aiter_bytes():
buffer.extend(chunk)
if len(buffer) >= MAX_PDF_BYTES:
break
raw_bytes = bytes(buffer)
except (httpx.HTTPError, BlockedAddressError) as exc:
logger.info("deepsearch httpx fetch failed for %s: %s", url, exc)
if is_pdf(content_type, url, raw_bytes[:5]):
title, text = extract_pdf_text(raw_bytes[:MAX_PDF_BYTES])
if len(text) < MIN_PAGE_CHARS:
return None
return CrawledPage(
url=url, title=title or url, text=text, source="pdf", status=status, depth=depth
)
if raw_bytes:
try:
raw = raw_bytes[:MAX_FETCH_BYTES].decode(encoding, errors="replace")
title, text = _strip_html(raw)
except (LookupError, ValueError) as exc:
logger.info("deepsearch decode failed for %s: %s", url, exc)
if len(text) < MIN_PAGE_CHARS:
try:
r_title, r_text, r_status = await _render_with_playwright(url)
@@ -6,8 +6,7 @@ import json
import logging
import re
import httpx
from devplacepy import stealth
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
logger = logging.getLogger(__name__)
@@ -68,7 +67,7 @@ async def plan_queries(query: str, api_key: str) -> list[str]:
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=ENHANCE_TIMEOUT_SECONDS) as client:
async with stealth.stealth_async_client(timeout=ENHANCE_TIMEOUT_SECONDS) as client:
response = await client.post(
INTERNAL_GATEWAY_URL, json=payload, headers=headers
)
@@ -8,8 +8,7 @@ import re
from dataclasses import dataclass, field
from urllib.parse import urlparse
import httpx
from devplacepy import stealth
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
logger = logging.getLogger(__name__)
@@ -73,7 +72,7 @@ async def _complete(messages: list[dict], api_key: str, max_tokens: int) -> str:
"temperature": 0.2,
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=AGENT_TIMEOUT_SECONDS) as client:
async with stealth.stealth_async_client(timeout=AGENT_TIMEOUT_SECONDS) as client:
response = await client.post(INTERNAL_GATEWAY_URL, json=payload, headers=headers)
if response.status_code >= 400:
raise RuntimeError(f"agent gateway returned {response.status_code}")
@@ -0,0 +1,67 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
import re
import tempfile
from pathlib import Path
from urllib.parse import urlsplit
from pypdf import PdfReader
from pypdf.errors import PdfReadError
logger = logging.getLogger(__name__)
MAX_PDF_BYTES = 15_000_000
MAX_PDF_PAGES = 50
PDF_MAGIC = b"%PDF-"
WHITESPACE = re.compile(r"[ \t]+")
BLANK_LINES = re.compile(r"\n{3,}")
def is_pdf(content_type: str, url: str, head: bytes) -> bool:
if "application/pdf" in content_type or "application/x-pdf" in content_type:
return True
if urlsplit(url).path.lower().endswith(".pdf"):
return True
return head.startswith(PDF_MAGIC)
def _normalize(text: str) -> str:
text = text.replace("\r\n", "\n").replace("\r", "\n")
text = WHITESPACE.sub(" ", text)
text = BLANK_LINES.sub("\n\n", text)
return text.strip()
def extract_pdf_text(raw: bytes) -> tuple[str, str]:
if not raw.startswith(PDF_MAGIC):
logger.debug("deepsearch pdf payload missing magic header (%d bytes)", len(raw))
return "", ""
handle = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
staged = Path(handle.name)
try:
handle.write(raw)
handle.close()
logger.info("deepsearch staged pdf at %s (%d bytes)", staged, len(raw))
reader = PdfReader(str(staged))
title = ""
if reader.metadata and reader.metadata.title:
title = str(reader.metadata.title).strip()
parts: list[str] = []
for index, page in enumerate(reader.pages):
if index >= MAX_PDF_PAGES:
logger.info("deepsearch pdf truncated at %d pages", MAX_PDF_PAGES)
break
extracted = page.extract_text() or ""
if extracted.strip():
parts.append(extracted)
text = _normalize("\n\n".join(parts))
logger.info("deepsearch extracted %d chars from pdf (%d pages)", len(text), len(parts))
return title, text
except (PdfReadError, ValueError, OSError) as exc:
logger.info("deepsearch pdf parse failed: %s", exc)
return "", ""
finally:
staged.unlink(missing_ok=True)
@@ -17,18 +17,18 @@ def _attribution(body: str, username: str) -> str:
return f"{body}\n\n---\n*Reported by **{username}** via DevPlace.*"
class BugCreateService(JobService):
kind = "bug_create"
title = "Bug filing"
class IssueCreateService(JobService):
kind = "issue_create"
title = "Issue filing"
description = (
"Files submitted bug reports off the request path: rewrites each report into a "
"Files submitted issue reports off the request path: rewrites each report into a "
"consistent, high-quality ticket with the internal AI service, posts it to the "
"configured Gitea repository, records the local author mapping, and notifies the "
"reporter. The Gitea issue is permanent; only the job tracking row is swept."
)
def __init__(self):
super().__init__(name="bug_create", interval_seconds=2)
super().__init__(name="issue_create", interval_seconds=2)
async def process(self, job: dict) -> dict:
payload = job["payload"]
@@ -68,43 +68,43 @@ class BugCreateService(JobService):
create_notification(
author_uid,
"bug",
f"Your bug report was filed as #{number}: {enhanced.title}",
"issue",
f"Your issue report was filed as #{number}: {enhanced.title}",
str(number),
f"/bugs/{number}",
f"/issues/{number}",
)
audit.record_system(
"bug.create",
"issue.create",
actor_kind="user",
actor_uid=author_uid,
actor_username=user.get("username"),
origin="devplace",
target_type="bug",
target_type="issue",
target_uid=str(number),
target_label=enhanced.title,
metadata={"number": number, "enhanced": enhanced.enhanced, "url": html_url},
summary=f"{user.get('username')} filed bug #{number}: {enhanced.title}",
links=[audit.target("bug", str(number), enhanced.title), audit.job(job["uid"])],
summary=f"{user.get('username')} filed issue #{number}: {enhanced.title}",
links=[audit.target("issue", str(number), enhanced.title), audit.job(job["uid"])],
)
self.log(f"Filed bug #{number} for {user['username']}")
self.log(f"Filed issue #{number} for {user['username']}")
return {
"number": number,
"html_url": html_url,
"bug_url": f"/bugs/{number}",
"issue_url": f"/issues/{number}",
"enhanced": enhanced.enhanced,
}
except Exception:
audit.record_system(
"bug.create",
"issue.create",
actor_kind="user",
actor_uid=author_uid,
actor_username=user.get("username") if user else None,
origin="devplace",
target_type="bug",
target_type="issue",
metadata={"title": title, "description": description[:200]},
result="failure",
summary=f"Bug filing failed for {user.get('username', 'unknown') if user else 'unknown'}: {title[:60]}",
summary=f"Issue filing failed for {user.get('username', 'unknown') if user else 'unknown'}: {title[:60]}",
links=[audit.job(job["uid"])],
)
raise
+2 -1
View File
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
import httpx
from devplacepy import stealth
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
from devplacepy.database import get_table, get_setting, internal_gateway_key
from devplacepy.services.base import BaseService, ConfigField
@@ -129,7 +130,7 @@ class NewsService(BaseService):
threshold = config["news_grade_threshold"]
self.log(f"Fetching news from {api_url}")
async with httpx.AsyncClient(timeout=30.0) as client:
async with stealth.stealth_async_client(timeout=30.0) as client:
try:
resp = await client.get(api_url)
resp.raise_for_status()
@@ -10,6 +10,7 @@ from typing import Optional
import httpx
from fastapi.responses import JSONResponse, Response, StreamingResponse
from devplacepy import stealth
from devplacepy.services.openai_gateway import config
from devplacepy.services.openai_gateway.reliability import CircuitBreaker, retry_send
from devplacepy.services.openai_gateway.system_message import apply_system_directives
@@ -118,7 +119,9 @@ class GatewayRuntime:
limits = httpx.Limits(
max_connections=instances, max_keepalive_connections=instances
)
self._client = httpx.AsyncClient(timeout=float(timeout), limits=limits)
self._client = stealth.stealth_async_client(
timeout=float(timeout), limits=limits
)
self._sem = asyncio.Semaphore(instances)
self._instances = instances
self._timeout = timeout
+78
View File
@@ -0,0 +1,78 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import subprocess
import sys
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
from devplacepy.services.base import BaseService
XMLRPC_INTERVAL_SECONDS = 15
SERVER_MODULE = "devplacepy.services.xmlrpc.server"
TERMINATE_TIMEOUT_SECONDS = 10
class XmlrpcService(BaseService):
title = "XML-RPC Bridge"
description = (
"Forking XML-RPC server that exposes every documented REST endpoint as an "
"XML-RPC method. Reachable at /xmlrpc through the app and nginx."
)
default_enabled = True
min_interval = 5
def __init__(self) -> None:
super().__init__("xmlrpc", interval_seconds=XMLRPC_INTERVAL_SECONDS)
self._process: subprocess.Popen | None = None
def _alive(self) -> bool:
return self._process is not None and self._process.poll() is None
def _spawn(self) -> None:
self._process = subprocess.Popen(
[sys.executable, "-m", SERVER_MODULE],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.log(
f"Forking XML-RPC server started (pid {self._process.pid}) on "
f"{XMLRPC_BIND}:{XMLRPC_PORT}"
)
def _terminate(self) -> None:
if not self._alive():
self._process = None
return
self._process.terminate()
try:
self._process.wait(timeout=TERMINATE_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait()
self.log("Forking XML-RPC server stopped")
self._process = None
async def on_enable(self) -> None:
if not self._alive():
self._spawn()
async def on_disable(self) -> None:
self._terminate()
async def run_once(self) -> None:
if self._alive():
self.log(f"XML-RPC server healthy (pid {self._process.pid})")
return
self.log("XML-RPC server not running, starting it")
self._spawn()
def collect_metrics(self) -> dict:
return {
"running": self._alive(),
"pid": self._process.pid if self._alive() else None,
"bind": f"{XMLRPC_BIND}:{XMLRPC_PORT}",
}
__all__ = ["XmlrpcService"]
+147
View File
@@ -0,0 +1,147 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
import xmlrpc.client
from typing import Any
from devplacepy import stealth
from devplacepy.config import INTERNAL_BASE_URL
from .registry import Method
logger = logging.getLogger("xmlrpc.bridge")
FAULT_INVALID_PARAMS = -32602
FAULT_MISSING_PARAM = -32001
FAULT_TRANSPORT = -32300
WRITE_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
TIMEOUT_SECONDS = 60.0
def _coerce_value(value: Any) -> str:
if isinstance(value, bool):
return "1" if value else "0"
return str(value)
def _build_path(method: Method, params: dict[str, Any]) -> str:
path = method.path
for param in method.path_params:
if param.name not in params:
raise xmlrpc.client.Fault(
FAULT_MISSING_PARAM,
f"Missing required path parameter '{param.name}' for {method.name}.",
)
path = path.replace("{" + param.name + "}", _coerce_value(params[param.name]))
return path
def _split_params(
method: Method, params: dict[str, Any]
) -> tuple[dict[str, str], dict[str, str]]:
declared = {p.name: p for p in method.params}
path_names = {p.name for p in method.path_params}
query: dict[str, str] = {}
form: dict[str, str] = {}
for key, value in params.items():
if key in path_names:
continue
spec = declared.get(key)
location = spec.location if spec else None
if location == "query":
query[key] = _coerce_value(value)
elif location == "form":
form[key] = _coerce_value(value)
elif method.http_method in WRITE_METHODS:
form[key] = _coerce_value(value)
else:
query[key] = _coerce_value(value)
return query, form
def _check_required(method: Method, params: dict[str, Any]) -> None:
for spec in method.params:
if spec.required and spec.name not in params:
raise xmlrpc.client.Fault(
FAULT_MISSING_PARAM,
f"Missing required parameter '{spec.name}' for {method.name}.",
)
def _decode(response: Any) -> Any:
content_type = response.headers.get("content-type", "")
if "application/json" in content_type:
try:
return response.json()
except ValueError:
return response.text
return response.text
def call(method: Method, params: dict[str, Any], auth: dict[str, str]) -> Any:
if not isinstance(params, dict):
raise xmlrpc.client.Fault(
FAULT_INVALID_PARAMS,
f"{method.name} expects a single struct of named parameters.",
)
params = dict(params)
api_key = str(params.pop("api_key", "") or auth.get("api_key", ""))
_check_required(method, params)
path = _build_path(method, params)
query, form = _split_params(method, params)
headers = {
"Accept": "application/json",
"X-Requested-With": "fetch",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["X-API-KEY"] = api_key
elif auth.get("authorization"):
headers["Authorization"] = auth["authorization"]
if auth.get("real_ip"):
headers["X-Real-IP"] = auth["real_ip"]
if auth.get("forwarded_for"):
headers["X-Forwarded-For"] = auth["forwarded_for"]
logger.debug("Bridge %s -> %s %s", method.name, method.http_method, path)
try:
with stealth.stealth_sync_client(
base_url=INTERNAL_BASE_URL,
timeout=TIMEOUT_SECONDS,
follow_redirects=True,
headers=headers,
) as client:
response = client.request(
method.http_method,
path,
params=query or None,
data=form or None,
)
except Exception as exc:
logger.warning("Bridge transport error for %s: %s", method.name, exc)
raise xmlrpc.client.Fault(
FAULT_TRANSPORT, f"Transport error calling {method.name}: {exc}"
)
body = _decode(response)
if response.status_code >= 400:
message = body
if isinstance(body, dict):
error = body.get("error")
if isinstance(error, dict):
message = error.get("message", body)
elif error:
message = error
else:
message = body.get("detail", body)
raise xmlrpc.client.Fault(response.status_code, _stringify(message))
return body
def _stringify(message: Any) -> str:
if isinstance(message, str):
return message
return str(message)
+120
View File
@@ -0,0 +1,120 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterator
from devplacepy import docs_api
@dataclass
class Param:
name: str
location: str
type: str
required: bool
description: str
options: list[str] = field(default_factory=list)
@dataclass
class Method:
name: str
http_method: str
path: str
title: str
summary: str
auth: str
group: str
destructive: bool
params: list[Param]
@property
def path_params(self) -> list[Param]:
return [p for p in self.params if p.location == "path"]
@property
def query_params(self) -> list[Param]:
return [p for p in self.params if p.location == "query"]
@property
def body_params(self) -> list[Param]:
return [p for p in self.params if p.location == "form"]
def _method_name(endpoint_id: str) -> str:
return endpoint_id.replace("-", ".")
def _to_param(spec: dict) -> Param:
return Param(
name=spec["name"],
location=spec.get("location", "form"),
type=spec.get("type", "string"),
required=bool(spec.get("required", False)),
description=spec.get("description", ""),
options=list(spec.get("options", []) or []),
)
def iter_methods() -> Iterator[Method]:
seen: set[str] = set()
for group in docs_api.API_GROUPS:
group_title = group.get("title", group.get("slug", ""))
for ep in group.get("endpoints", []) or []:
name = _method_name(ep["id"])
if name in seen:
continue
seen.add(name)
yield Method(
name=name,
http_method=ep["method"].upper(),
path=ep["path"],
title=ep.get("title", name),
summary=ep.get("summary", ""),
auth=ep.get("auth", "user"),
group=group_title,
destructive=bool(ep.get("destructive", False)),
params=[_to_param(p) for p in ep.get("params", []) or []],
)
def build_index() -> dict[str, Method]:
return {method.name: method for method in iter_methods()}
def method_help(method: Method) -> str:
lines = [
f"{method.title} - {method.http_method} {method.path}",
f"Group: {method.group} | Auth: {method.auth}",
"",
method.summary,
"",
"Call with a single struct of named parameters, for example:",
f" proxy.{method.name}({{{_example_struct(method)}}})",
"",
"Authenticate by passing 'api_key' inside the struct, or with an "
"X-API-KEY / Authorization Bearer header on the HTTP transport.",
]
if method.params:
lines.append("")
lines.append("Parameters:")
for p in method.params:
flag = "required" if p.required else "optional"
extra = f" (one of: {', '.join(p.options)})" if p.options else ""
lines.append(
f" - {p.name} [{p.location}, {p.type}, {flag}]: {p.description}{extra}"
)
return "\n".join(lines)
def method_signature(method: Method) -> list[list[str]]:
return [["struct", "struct"]]
def _example_struct(method: Method) -> str:
pieces = [f"'{p.name}': ..." for p in method.params if p.required]
if method.auth != "public":
pieces.append("'api_key': ...")
return ", ".join(pieces)
+115
View File
@@ -0,0 +1,115 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
from socketserver import ForkingMixIn
from xmlrpc.server import SimpleXMLRPCDispatcher, SimpleXMLRPCRequestHandler, SimpleXMLRPCServer
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
from . import bridge
from .registry import Method, build_index, method_help, method_signature
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("xmlrpc.server")
RPC_PATHS = ("/", "/RPC2", "/xmlrpc")
def _auth_from_headers(headers) -> dict[str, str]:
auth: dict[str, str] = {}
api_key = headers.get("X-API-KEY") or headers.get("x-api-key")
authorization = headers.get("Authorization") or headers.get("authorization") or ""
if not api_key and authorization.lower().startswith("bearer "):
api_key = authorization[7:].strip()
if authorization.lower().startswith("basic "):
auth["authorization"] = authorization.strip()
if api_key:
auth["api_key"] = api_key.strip()
real_ip = headers.get("X-Real-IP") or headers.get("x-real-ip")
if real_ip:
auth["real_ip"] = real_ip
forwarded = headers.get("X-Forwarded-For") or headers.get("x-forwarded-for")
if forwarded:
auth["forwarded_for"] = forwarded
return auth
class _RequestHandler(SimpleXMLRPCRequestHandler):
rpc_paths = RPC_PATHS
def decode_request_content(self, data):
self.server.current_auth = _auth_from_headers(self.headers)
return super().decode_request_content(data)
def log_message(self, *args, **kwargs):
return
class BridgeDispatcher(SimpleXMLRPCDispatcher):
def system_methodHelp(self, method_name):
target = self.index.get(method_name)
if target is not None:
return method_help(target)
return super().system_methodHelp(method_name)
def system_methodSignature(self, method_name):
target = self.index.get(method_name)
if target is not None:
return method_signature(target)
return super().system_methodSignature(method_name)
class ForkingXMLRPCServer(ForkingMixIn, BridgeDispatcher, SimpleXMLRPCServer):
allow_reuse_address = True
daemon_threads = True
def __init__(self, addr, index):
self.index = index
self.current_auth: dict[str, str] = {}
SimpleXMLRPCServer.__init__(
self,
addr,
requestHandler=_RequestHandler,
allow_none=True,
logRequests=False,
)
def _make_handler(server: "ForkingXMLRPCServer", method: Method):
def handler(*args):
params = args[0] if args else {}
return bridge.call(method, params, server.current_auth)
handler.__name__ = method.name
handler.__doc__ = method_help(method)
return handler
def build_server(host: str, port: int) -> ForkingXMLRPCServer:
index = build_index()
server = ForkingXMLRPCServer((host, port), index)
server.register_introspection_functions()
server.register_multicall_functions()
for name, method in index.items():
server.register_function(_make_handler(server, method), name)
logger.info("XML-RPC bridge registered %d methods", len(index))
return server
def main() -> None:
server = build_server(XMLRPC_BIND, XMLRPC_PORT)
logger.info("Forking XML-RPC server listening on %s:%d", XMLRPC_BIND, XMLRPC_PORT)
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("XML-RPC server interrupted")
finally:
server.server_close()
if __name__ == "__main__":
main()