This commit is contained in:
2026-07-19 18:57:43 +02:00
parent 48bb6c2ec2
commit c53e2a3319
179 changed files with 9307 additions and 897 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ Prefixes are wired in `main.py`:
| `/comments` | comments.py |
| `/projects` | projects/ package - `index.py` (listing/detail/create/delete plus owner-only visibility toggles `POST /{slug}/private` and `POST /{slug}/readonly`, and async `POST /{slug}/fork`), `files.py`, and the `containers/` subpackage (below). `main.py` mounts the whole `/projects` tree from this one package. See `routers/projects/CLAUDE.md` for the deep detail on this tree |
| `/projects/{slug}/files` | projects/files.py - per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: `lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append` for large text files) |
| `/profile` | profile/ package - `index.py` (page/search/followers/update/api-key; the **Posts** tab shows only the 10 most recent posts, while the sidebar `posts_count` stat always uses the real `count()` so it is never capped to the rendered 10), `customization.py`, `notifications.py`, `ai_correction.py` (`POST /{username}/ai-correction`, owner-or-admin AI content correction toggle + prompt), `ai_modifier.py` (`POST /{username}/ai-modifier`, owner-or-admin AI modifier toggle + prompt), `telegram.py` (`POST /{username}/telegram`, owner-or-admin Telegram pairing-code request/unpair), `usage.py` (`_ai_quota`, re-exported from the package) |
| `/profile` | profile/ package - `index.py` (page/search/followers/update/api-key; the **Posts** tab shows only the 10 most recent posts, while the sidebar `posts_count` stat always uses the real `count()` so it is never capped to the rendered 10), `customization.py`, `notifications.py`, `ai_correction.py` (`POST /{username}/ai-correction`, owner-or-admin AI content correction toggle + prompt), `ai_modifier.py` (`POST /{username}/ai-modifier`, owner-or-admin AI modifier toggle + prompt), `interactions.py` (`POST /{username}/interactions`, owner-or-admin Devii interactive widgets preference; admin default via `devii_interactions_default`), `telegram.py` (`POST /{username}/telegram`, owner-or-admin Telegram pairing-code request/unpair), `usage.py` (`_ai_quota`, re-exported from the package) |
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `POST /send` (no-JS fallback), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, read receipts, and in-process presence on top of the existing `messages` table; both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live) and broadcasts the FINAL corrected/modified content (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
| `/notifications` | notifications.py |
| `/votes` | votes.py |
+3 -4
View File
@@ -17,15 +17,14 @@ _CACHE_CONTROL = f"public, max-age={SECONDS_PER_DAY}, immutable"
@router.get("/{style}/{seed}")
async def avatar_proxy(request: Request, style: str, seed: str, size: int = 128):
cache_key = f"{seed}:{size}"
etag = '"' + hashlib.md5(cache_key.encode("utf-8")).hexdigest() + '"'
etag = '"' + hashlib.md5(f"{seed}:{size}".encode("utf-8")).hexdigest() + '"'
headers = {"ETag": etag, "Cache-Control": _CACHE_CONTROL}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
svg = _cache.get(cache_key)
svg = _cache.get(seed)
if svg is None:
svg = generate_avatar_svg(seed)
_cache.set(cache_key, svg)
_cache.set(seed, svg)
return Response(content=svg, media_type="image/svg+xml", headers=headers)
+11 -1
View File
@@ -185,7 +185,10 @@ async def clippy_proxy(request: Request):
return JSONResponse({"error": "Devii is unavailable"}, status_code=503)
cfg = svc.effective_config()
body = await request.body()
headers = {"Content-Type": "application/json"}
headers = {
"Content-Type": "application/json",
"X-App-Reference": "devplace-devii-v-1-0-0",
}
if cfg.get("devii_ai_key"):
headers["Authorization"] = f"Bearer {cfg['devii_ai_key']}"
async with stealth.stealth_async_client(timeout=45.0) as client:
@@ -260,6 +263,8 @@ async def devii_ws(websocket: WebSocket):
if command == "reset":
await session.reset()
continue
if await session.try_answer_interaction(text):
continue
if svc.quota_exceeded(owner_kind, owner_id, owner_is_admin):
limit = svc.daily_limit_for(owner_kind, owner_is_admin)
audit.record_system(
@@ -302,6 +307,11 @@ async def devii_ws(websocket: WebSocket):
)
elif kind in ("avatar_result", "client_result"):
session.resolve_query(str(data.get("id", "")), data.get("result"))
elif kind == "interaction_result":
session.resolve_interaction(
str(data.get("id", data.get("interaction_id", ""))),
data.get("result") or data,
)
except WebSocketDisconnect:
pass
except Exception: # noqa: BLE001 - never let the socket loop crash the worker
+1
View File
@@ -57,6 +57,7 @@ LANGUAGES = [
("yaml", "YAML"),
("json", "JSON"),
("markdown", "Markdown"),
("markdown_rendered", "Markdown Rendered"),
("swift", "Swift"),
("php", "PHP"),
("ruby", "Ruby"),
+1 -1
View File
@@ -231,7 +231,7 @@ async def send_message(request: Request, data: Annotated[MessageForm, Depends(js
async def broadcast_message(
sender: dict, message: dict, client_id: Optional[str] = None
) -> None:
frame = message_frame(message, sender.get("username", ""), client_id)
frame = message_frame(message, sender.get("username", ""), client_id, sender_role=sender.get("role"))
message_hub.mark_delivered(message["uid"])
targets = [message["sender_uid"], message["receiver_uid"]]
await message_hub.send_to_users(targets, frame)
+2
View File
@@ -6,6 +6,7 @@ from devplacepy.routers.profile import (
avatar,
award,
customization,
interactions,
notifications,
telegram,
)
@@ -17,6 +18,7 @@ router.include_router(customization.router)
router.include_router(notifications.router)
router.include_router(ai_correction.router)
router.include_router(ai_modifier.router)
router.include_router(interactions.router)
router.include_router(avatar.router)
router.include_router(telegram.router)
+18 -3
View File
@@ -12,6 +12,7 @@ from devplacepy.database import (
get_notification_prefs,
get_user_stars,
get_user_rank,
get_user_post_count,
get_comment_counts_by_post_uids,
get_reactions_by_targets,
get_user_bookmarks,
@@ -211,9 +212,7 @@ async def profile_page(
)
for g in gists_raw:
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
posts_count = get_table("posts").count(
user_uid=profile_user["uid"], deleted_at=None
)
posts_count = get_user_post_count(profile_user["uid"])
activities = []
if tab == "activity":
@@ -302,6 +301,18 @@ async def profile_page(
if is_owner
else None
)
from devplacepy.services.devii.interaction import prefs as interaction_prefs
interactions_snap = (
interaction_prefs.snapshot("user", profile_user["uid"], profile_user)
if is_owner
else {
"enabled": True,
"source": None,
"default": True,
"override": None,
}
)
from devplacepy.services.telegram import store as telegram_store
telegram_paired = (
@@ -402,6 +413,10 @@ async def profile_page(
"ai_modifier_enabled": ai_modifier_enabled,
"ai_modifier_sync": ai_modifier_sync,
"ai_modifier_prompt": ai_modifier_prompt,
"interactions_enabled": interactions_snap["enabled"],
"interactions_source": interactions_snap["source"],
"interactions_default": interactions_snap["default"],
"interactions_override": interactions_snap["override"],
"telegram_paired": telegram_paired,
"notif_telegram_paired": notif_telegram_paired,
"can_manage_customization": can_manage_customization,
@@ -0,0 +1,61 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import Depends, APIRouter, Request
from devplacepy.models import InteractionsForm
from devplacepy.responses import action_result
from devplacepy.services.audit import record as audit
from devplacepy.services.devii.interaction import prefs
from devplacepy.routers.profile._shared import resolve_customization_target
from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{username}/interactions")
async def set_interactions(
request: Request,
username: str,
data: Annotated[InteractionsForm, Depends(json_or_form(InteractionsForm))],
):
target, denied = resolve_customization_target(request, username)
if denied is not None:
return denied
if data.reset:
snap = prefs.set_user_pref(target["uid"], None)
summary = f"reset interactive widgets to admin default for {target['username']}"
new_value = -1
else:
snap = prefs.set_user_pref(target["uid"], bool(data.enabled))
summary = (
f"{'enabled' if data.enabled else 'disabled'} interactive widgets "
f"for {target['username']}"
)
new_value = 1 if data.enabled else 0
logger.info(summary)
audit.record(
request,
"profile.interactions",
target_type="user",
target_uid=target["uid"],
target_label=target["username"],
new_value=new_value,
summary=summary,
links=[audit.target("user", target["uid"], target["username"])],
)
url = f"/profile/{target['username']}"
return action_result(
request,
url,
data={
"url": url,
"enabled": snap["enabled"],
"source": snap["source"],
"default": snap["default"],
"override": snap["override"],
},
)
+6 -1
View File
@@ -4,6 +4,7 @@ import logging
import httpx
from fastapi import APIRouter, Request
from starlette.requests import ClientDisconnect
from starlette.responses import PlainTextResponse, Response
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
@@ -49,7 +50,11 @@ async def info(request: Request, path: str = "") -> PlainTextResponse:
@router.post("/")
@router.post("/{path:path}")
async def proxy(request: Request, path: str = "") -> Response:
body = await request.body()
try:
body = await request.body()
except ClientDisconnect:
logger.warning("XML-RPC client disconnected before request body was read")
return PlainTextResponse("Client disconnected", status_code=400)
headers = {
key: value
for key, value in request.headers.items()