Compare commits

...

12 Commits

Author SHA1 Message Date
67caf3f479 Update
Some checks failed
DevPlace CI / test (push) Failing after 37m34s
2026-07-04 22:24:59 +02:00
0d560c3d18 refactor: split database.py into package (ref.md 3.2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:33:00 +02:00
63125e7aa5 refactor: split utils.py into package (ref.md 3.8) 2026-07-04 21:33:00 +02:00
df03febff0 refactor: split docs_api.py into package (ref.md 3.1) 2026-07-04 21:33:00 +02:00
c8be71219d refactor: split cli.py into package (ref.md 3.7) 2026-07-04 21:33:00 +02:00
12285ecba3 refactor: split bot.py into mixins (ref.md 3.3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:33:00 +02:00
caf59108a7 refactor: split devii session.py into package (ref.md 3.10) 2026-07-04 21:33:00 +02:00
cd37d19d8d refactor: split news.py into package (ref.md 3.9)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:33:00 +02:00
32d4ee6e69 refactor: split schemas.py into package (ref.md 3.6) 2026-07-04 21:33:00 +02:00
afb954deaa refactor: split devii catalog.py into package (ref.md 3.5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:33:00 +02:00
c60d89b304 refactor: split game store.py into subpackage (ref.md 3.11) 2026-07-04 21:33:00 +02:00
6941c51560 chore: add monster-file split plan (ref.md) 2026-07-04 21:33:00 +02:00
153 changed files with 22071 additions and 19212 deletions

View File

@ -510,6 +510,30 @@ code stays tiny. Reach for these instead of hand-rolling a loop, a fetch, or a c
`window.innerWidth` change closes it. All paths fall back to `window.inner*` when `visualViewport`
is absent, so desktop and old browsers are untouched.
## Profile activity tab (clickable comments)
The profile **Activity** tab (`/profile/{username}?tab=activity`, public) interleaves the user's 10
most recent posts and 10 most recent comments, newest first. Each activity item is a plain dict
built inline in `routers/profile/index.py` (no dedicated DB helper) and carries a `url` so the whole
card is clickable, exactly like the feed's post cards:
- **Post items** get `"url": resolve_object_url("post", p["uid"])` -> `/posts/{slug}`.
- **Comment items** get `"url": f"{resolve_object_url(target_type, target_uid)}#comment-{c['uid']}"`,
the SEO-friendly parent detail URL plus the `#comment-{uid}` anchor. This is **byte-identical to a
comment notification's `target_url`** (`content.create_comment_record`), so clicking an activity
comment reproduces the notification click exactly: it lands on the parent post and
`NotificationManager.js` scrolls to / highlights the `id="comment-{uid}"` element
(`_comment.html`). Reuse `resolve_object_url` (`database.py`) - never rebuild this URL by hand, and
do not call `resolve_object_url("comment", uid)` here (it re-fetches the row the loop already has).
- **Frontend:** the card is wrapped in the shared full-card overlay link (`card-link-host` +
`_card_link.html`), the same pattern `_post_card.html` uses; inner content links (mentions, URLs
rendered by `render_content`) stay clickable via the existing `card-link-host a:not(.card-link)`
z-index rule (`base.css`). No new CSS.
- **API:** `ProfileOut.activities` is `list[Any]`, so the added `url` (and, on comments, the existing
`target_type` + `uid` = parent target uid) flow into the JSON response with no schema change - this
is the parent-post reference exposed on each comment item. Covered by `tests/api/profile/index.py`
(activity `url` assertions) and `tests/e2e/profile/index.py` (click-through).
## Profile media gallery and soft-deleted attachments
The profile **Media** tab (`/profile/{username}?tab=media`, public) is a paginated grid of every

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,83 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli.main import main, build_parser
from devplacepy.cli._shared import _audit_cli
from devplacepy.cli.roles import cmd_role_get, cmd_role_set
from devplacepy.cli.apikeys import cmd_apikey_get, cmd_apikey_reset, cmd_apikey_backfill
from devplacepy.cli.tokens import (
cmd_token_issue,
cmd_token_list,
cmd_token_revoke,
cmd_token_revoke_all,
cmd_token_prune,
)
from devplacepy.cli.devii import cmd_devii_reset_quota
from devplacepy.cli.news import cmd_news_clear, cmd_news_sanitize
from devplacepy.cli.attachments import cmd_attachments_prune
from devplacepy.cli.jobs import (
cmd_zips_prune,
cmd_zips_clear,
cmd_forks_prune,
cmd_forks_clear,
cmd_seo_prune,
cmd_seo_clear,
cmd_seo_meta_prune,
cmd_seo_meta_clear,
cmd_deepsearch_prune,
cmd_deepsearch_clear,
)
from devplacepy.cli.backups import (
cmd_backups_list,
cmd_backups_run,
cmd_backups_prune,
cmd_backups_clear,
)
from devplacepy.cli.containers import (
cmd_containers_list,
cmd_containers_reconcile,
cmd_containers_prune,
cmd_containers_prune_builds,
cmd_containers_gc_workspaces,
)
from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data
__all__ = [
"main",
"build_parser",
"_audit_cli",
"cmd_role_get",
"cmd_role_set",
"cmd_apikey_get",
"cmd_apikey_reset",
"cmd_apikey_backfill",
"cmd_token_issue",
"cmd_token_list",
"cmd_token_revoke",
"cmd_token_revoke_all",
"cmd_token_prune",
"cmd_devii_reset_quota",
"cmd_news_clear",
"cmd_news_sanitize",
"cmd_attachments_prune",
"cmd_zips_prune",
"cmd_zips_clear",
"cmd_forks_prune",
"cmd_forks_clear",
"cmd_seo_prune",
"cmd_seo_clear",
"cmd_seo_meta_prune",
"cmd_seo_meta_clear",
"cmd_deepsearch_prune",
"cmd_deepsearch_clear",
"cmd_backups_list",
"cmd_backups_run",
"cmd_backups_prune",
"cmd_backups_clear",
"cmd_containers_list",
"cmd_containers_reconcile",
"cmd_containers_prune",
"cmd_containers_prune_builds",
"cmd_containers_gc_workspaces",
"cmd_emoji_sync",
"cmd_migrate_data",
]

View File

@ -0,0 +1,6 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli.main import main
if __name__ == "__main__":
main()

18
devplacepy/cli/_shared.py Normal file
View File

@ -0,0 +1,18 @@
# retoor <retoor@molodetz.nl>
def _audit_cli(event_key, summary, metadata=None, target_type=None, target_uid=None, target_label=None, links=None):
from devplacepy.services.audit import record as audit
audit.record_system(
event_key,
actor_kind="cli",
actor_role="system",
origin="cli",
target_type=target_type,
target_uid=target_uid,
target_label=target_label,
summary=summary,
metadata=metadata,
links=links,
)

65
devplacepy/cli/apikeys.py Normal file
View File

@ -0,0 +1,65 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import get_table
from devplacepy.cli._shared import _audit_cli
def cmd_apikey_get(args):
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
print(user.get("api_key", "") or "")
def cmd_apikey_reset(args):
from devplacepy.utils import generate_uid, clear_user_cache
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
new_key = generate_uid()
users.update({"uid": user["uid"], "api_key": new_key}, ["uid"])
clear_user_cache(user["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.apikey.reset",
f"CLI regenerated the API key of user {args.username}",
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(new_key)
def cmd_apikey_backfill(args):
from devplacepy.database import backfill_api_keys
updated = backfill_api_keys()
_audit_cli(
"cli.apikey.backfill",
f"CLI backfilled API keys for {updated} users",
metadata={"count": updated},
)
print(f"Assigned API keys to {updated} user(s) without one")
def register_apikeys(subparsers):
apikey = subparsers.add_parser("apikey", help="Manage user API keys")
apikey_sub = apikey.add_subparsers(title="action", dest="action")
apikey_get = apikey_sub.add_parser("get", help="Print a user's API key")
apikey_get.add_argument("username")
apikey_get.set_defaults(func=cmd_apikey_get)
apikey_reset = apikey_sub.add_parser("reset", help="Regenerate a user's API key")
apikey_reset.add_argument("username")
apikey_reset.set_defaults(func=cmd_apikey_reset)
apikey_backfill = apikey_sub.add_parser(
"backfill", help="Assign API keys to users that lack one"
)
apikey_backfill.set_defaults(func=cmd_apikey_backfill)

View File

@ -0,0 +1,43 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_attachments_prune(args):
from datetime import datetime, timezone, timedelta
from devplacepy.database import db
from devplacepy.attachments import delete_attachment
if "attachments" not in db.tables:
print("Attachments table does not exist")
return
cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat()
orphans = [
att
for att in db["attachments"].find(target_type="", target_uid="")
if att.get("created_at", "") < cutoff
]
for att in orphans:
delete_attachment(att["uid"])
_audit_cli(
"cli.attachments.prune",
f"CLI pruned {len(orphans)} orphan attachments",
metadata={"count": len(orphans), "hours": args.hours},
)
print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")
def register_attachments(subparsers):
attachments = subparsers.add_parser("attachments", help="Attachment management")
att_sub = attachments.add_subparsers(title="action", dest="action")
att_prune = att_sub.add_parser(
"prune", help="Remove orphaned attachment records and files"
)
att_prune.add_argument(
"--hours",
type=int,
default=24,
help="Only prune orphans older than this many hours",
)
att_prune.set_defaults(func=cmd_attachments_prune)

82
devplacepy/cli/backups.py Normal file
View File

@ -0,0 +1,82 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def cmd_backups_list(args):
from devplacepy.services.backup import store
backups = store.list_backups()
if not backups:
print("No backups recorded")
return
for backup in backups:
size = store.human_bytes(int(backup.get("size_bytes") or 0))
print(
f"{backup['uid']} {backup.get('target', ''):<9} "
f"{backup.get('status', ''):<8} {size:>10} "
f"{backup.get('created_at', '')} {backup.get('filename', '')}"
)
def cmd_backups_run(args):
from devplacepy.services.backup import store
from devplacepy.services.jobs import queue
if not store.is_valid_target(args.target):
print(f"Unknown target '{args.target}'. Choose one of: {', '.join(store.BACKUP_TARGETS)}")
sys.exit(1)
job_uid = queue.enqueue(
"backup",
{"target": args.target, "schedule_uid": "", "created_by": "cli"},
owner_kind="system",
owner_id="cli",
preferred_name=f"{store.target_label(args.target)} (cli)",
)
store.create_backup(target=args.target, created_by="cli", job_uid=job_uid)
_audit_cli(
"cli.backups.run",
f"CLI enqueued {args.target} backup",
metadata={"target": args.target, "job_uid": job_uid},
)
print(f"Enqueued {args.target} backup job {job_uid} (processed by the running server)")
def cmd_backups_prune(args):
from devplacepy.services.backup import store
removed = store.prune_orphans()
_audit_cli("cli.backups.prune", f"CLI pruned {removed} orphan backups", metadata={"count": removed})
print(f"Pruned {removed} orphan backup record(s)")
def cmd_backups_clear(args):
from devplacepy.services.backup import store
removed = store.clear_all()
_audit_cli("cli.backups.clear", f"CLI cleared all backups ({removed})", metadata={"count": removed})
print(f"Cleared {removed} backup(s) and their archives")
def register_backups(subparsers):
backups = subparsers.add_parser("backups", help="Backup management")
backups_sub = backups.add_subparsers(title="action", dest="action")
backups_sub.add_parser("list", help="List recorded backups").set_defaults(
func=cmd_backups_list
)
backups_run = backups_sub.add_parser(
"run", help="Enqueue a backup (processed by the running server)"
)
backups_run.add_argument(
"target",
choices=["database", "uploads", "keys", "full"],
help="What to back up",
)
backups_run.set_defaults(func=cmd_backups_run)
backups_sub.add_parser(
"prune", help="Remove backup records whose archive file is missing"
).set_defaults(func=cmd_backups_prune)
backups_sub.add_parser(
"clear", help="Delete every backup archive and record"
).set_defaults(func=cmd_backups_clear)

View File

@ -0,0 +1,107 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_containers_list(args):
from devplacepy.services.containers import store
instances = store.all_instances()
if not instances:
print("No container instances")
return
for inst in instances:
print(
f"{inst['uid'][:8]} {inst.get('name', ''):24.24} {inst.get('status', ''):10} "
f"desired={inst.get('desired_state', '')} policy={inst.get('restart_policy', '')}"
)
def cmd_containers_reconcile(args):
import asyncio
from devplacepy.services.containers.service import ContainerService
asyncio.run(ContainerService().run_once())
_audit_cli("cli.containers.reconcile", "CLI ran one container reconcile pass")
print("Reconcile pass complete")
def cmd_containers_prune(args):
import asyncio
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.containers.service import ContainerService
async def run():
await ContainerService().run_once()
await get_backend().image_prune()
asyncio.run(run())
_audit_cli("cli.containers.prune", "CLI reaped orphan containers and dangling images")
print("Reaped orphans and pruned dangling images")
def cmd_containers_prune_builds(args):
import asyncio
from devplacepy.database import db, get_table
from devplacepy.services.containers.runtime import get_backend
async def run():
backend = get_backend()
removed = 0
if "builds" in db.tables:
for build in list(get_table("builds").find()):
tag = build.get("image_tag")
if tag:
await backend.remove_image(tag)
removed += 1
for table in ("builds", "dockerfile_versions", "dockerfiles"):
if table in db.tables:
get_table(table).delete()
return removed
removed = asyncio.run(run())
_audit_cli("cli.containers.prune_builds", "CLI removed legacy images and build tables", metadata={"removed": removed})
print(
f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables"
)
def cmd_containers_gc_workspaces(args):
import shutil
from pathlib import Path
from devplacepy import config
from devplacepy.services.containers import store
active = {inst["project_uid"] for inst in store.all_instances()}
base = Path(config.CONTAINER_WORKSPACES_DIR)
removed = 0
if base.is_dir():
for child in base.iterdir():
if child.is_dir() and child.name not in active:
shutil.rmtree(child, ignore_errors=True)
removed += 1
_audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed})
print(
f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}"
)
def register_containers(subparsers):
containers = subparsers.add_parser("containers", help="Container manager")
containers_sub = containers.add_subparsers(title="action", dest="action")
containers_sub.add_parser("list", help="List container instances").set_defaults(
func=cmd_containers_list
)
containers_sub.add_parser("reconcile", help="Run one reconcile pass").set_defaults(
func=cmd_containers_reconcile
)
containers_sub.add_parser(
"prune", help="Reap orphan containers and dangling images"
).set_defaults(func=cmd_containers_prune)
containers_sub.add_parser(
"prune-builds",
help="Remove legacy per-project images and clear the dockerfiles/builds tables",
).set_defaults(func=cmd_containers_prune_builds)
containers_sub.add_parser(
"gc-workspaces", help="Remove workspace dirs with no instances"
).set_defaults(func=cmd_containers_gc_workspaces)

66
devplacepy/cli/devii.py Normal file
View File

@ -0,0 +1,66 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import get_table
from devplacepy.cli._shared import _audit_cli
def cmd_devii_reset_quota(args):
from devplacepy.database import db
table_name = "devii_usage_ledger"
if table_name not in db.tables:
print(f"Table '{table_name}' does not exist, nothing to reset")
return
table = db[table_name]
if args.all:
count = table.count()
table.delete()
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (all)", metadata={"scope": "all", "rows_removed": count})
print(f"Reset all AI quotas ({count} ledger rows deleted)")
return
if args.guests:
count = table.count(owner_kind="guest")
table.delete(owner_kind="guest")
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (guests)", metadata={"scope": "guests", "rows_removed": count})
print(f"Reset all guest AI quotas ({count} ledger rows deleted)")
return
if not args.username:
print("Provide a username, or --guests, or --all")
sys.exit(1)
user = get_table("users").find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
count = table.count(owner_kind="user", owner_id=user["uid"])
table.delete(owner_kind="user", owner_id=user["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.devii.quota.reset",
f"CLI reset AI quota for {args.username}",
metadata={"scope": "user", "rows_removed": count},
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)")
def register_devii(subparsers):
devii = subparsers.add_parser("devii", help="Devii assistant management")
devii_sub = devii.add_subparsers(title="action", dest="action")
devii_reset = devii_sub.add_parser(
"reset-quota", help="Reset the rolling 24h AI spend quota"
)
devii_reset.add_argument(
"username", nargs="?", help="Reset the quota for a single user"
)
devii_reset.add_argument(
"--guests", action="store_true", help="Reset every guest quota"
)
devii_reset.add_argument(
"--all", action="store_true", help="Reset every quota (users and guests)"
)
devii_reset.set_defaults(func=cmd_devii_reset_quota)

267
devplacepy/cli/jobs.py Normal file
View File

@ -0,0 +1,267 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_table
from devplacepy.cli._shared import _audit_cli
def _remove_zip_artifacts(job):
import shutil
from pathlib import Path
from devplacepy.services.jobs.zip_service import STAGING_DIR
local_path = (job.get("result") or {}).get("local_path")
if local_path:
Path(local_path).unlink(missing_ok=True)
shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True)
def cmd_zips_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="zip", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_zip_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.zips.prune", f"CLI pruned {removed} expired zip jobs", metadata={"count": removed})
print(f"Pruned {removed} expired zip job(s)")
def cmd_zips_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="zip")
for job in jobs:
_remove_zip_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.zips.clear", f"CLI cleared all zip jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} zip job(s) and their archives")
def cmd_forks_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="fork", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.forks.prune", f"CLI pruned {removed} expired fork jobs", metadata={"count": removed})
print(f"Pruned {removed} expired fork job(s)")
def cmd_forks_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="fork")
for job in jobs:
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.forks.clear", f"CLI cleared all fork jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} fork job(s)")
def _remove_seo_artifacts(job):
import shutil
from devplacepy.config import SEO_REPORTS_DIR
shutil.rmtree(SEO_REPORTS_DIR / job["uid"], ignore_errors=True)
def cmd_seo_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="seo", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_seo_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.seo.prune", f"CLI pruned {removed} expired SEO jobs", metadata={"count": removed})
print(f"Pruned {removed} expired SEO audit(s)")
def cmd_seo_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="seo")
for job in jobs:
_remove_seo_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.seo.clear", f"CLI cleared all SEO jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} SEO audit(s) and their reports")
def cmd_seo_meta_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="seo_meta", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli(
"cli.seo_meta.prune",
f"CLI pruned {removed} expired SEO metadata jobs",
metadata={"count": removed},
)
print(f"Pruned {removed} expired SEO metadata job(s)")
def cmd_seo_meta_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="seo_meta")
for job in jobs:
get_table("jobs").delete(uid=job["uid"])
_audit_cli(
"cli.seo_meta.clear",
f"CLI cleared all SEO metadata jobs ({len(jobs)})",
metadata={"count": len(jobs)},
)
print(f"Cleared {len(jobs)} SEO metadata job(s); generated metadata persists")
def _remove_deepsearch_artifacts(job):
import shutil
from devplacepy.config import DEEPSEARCH_DIR
from devplacepy.services.deepsearch.store import VectorStore
uid = job["uid"]
collection = f"ds_{uid.replace('-', '')}"
VectorStore(collection).drop()
shutil.rmtree(DEEPSEARCH_DIR / uid, ignore_errors=True)
def cmd_deepsearch_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="deepsearch", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_deepsearch_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli(
"cli.deepsearch.prune",
f"CLI pruned {removed} expired DeepSearch jobs",
metadata={"count": removed},
)
print(f"Pruned {removed} expired DeepSearch job(s)")
def cmd_deepsearch_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="deepsearch")
for job in jobs:
_remove_deepsearch_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli(
"cli.deepsearch.clear",
f"CLI cleared all DeepSearch jobs ({len(jobs)})",
metadata={"count": len(jobs)},
)
print(f"Cleared {len(jobs)} DeepSearch job(s) and their collections")
def register_jobs(subparsers):
zips = subparsers.add_parser("zips", help="Zip archive job management")
zips_sub = zips.add_subparsers(title="action", dest="action")
zips_prune = zips_sub.add_parser(
"prune", help="Delete expired zip archives and their job rows"
)
zips_prune.set_defaults(func=cmd_zips_prune)
zips_clear = zips_sub.add_parser(
"clear", help="Delete every zip archive and job row"
)
zips_clear.set_defaults(func=cmd_zips_clear)
forks = subparsers.add_parser("forks", help="Fork job management")
forks_sub = forks.add_subparsers(title="action", dest="action")
forks_prune = forks_sub.add_parser(
"prune", help="Delete expired completed fork job rows (forked projects persist)"
)
forks_prune.set_defaults(func=cmd_forks_prune)
forks_clear = forks_sub.add_parser(
"clear", help="Delete every fork job row (forked projects persist)"
)
forks_clear.set_defaults(func=cmd_forks_clear)
seo = subparsers.add_parser("seo", help="SEO Diagnostics job management")
seo_sub = seo.add_subparsers(title="action", dest="action")
seo_prune = seo_sub.add_parser(
"prune", help="Delete expired SEO audit reports and their job rows"
)
seo_prune.set_defaults(func=cmd_seo_prune)
seo_clear = seo_sub.add_parser(
"clear", help="Delete every SEO audit report and job row"
)
seo_clear.set_defaults(func=cmd_seo_clear)
seo_meta = subparsers.add_parser("seo-meta", help="SEO metadata job management")
seo_meta_sub = seo_meta.add_subparsers(title="action", dest="action")
seo_meta_prune = seo_meta_sub.add_parser(
"prune", help="Delete expired SEO metadata job rows (generated metadata persists)"
)
seo_meta_prune.set_defaults(func=cmd_seo_meta_prune)
seo_meta_clear = seo_meta_sub.add_parser(
"clear", help="Delete every SEO metadata job row (generated metadata persists)"
)
seo_meta_clear.set_defaults(func=cmd_seo_meta_clear)
deepsearch = subparsers.add_parser("deepsearch", help="DeepSearch job management")
deepsearch_sub = deepsearch.add_subparsers(title="action", dest="action")
deepsearch_prune = deepsearch_sub.add_parser(
"prune", help="Delete expired DeepSearch sessions and their job rows"
)
deepsearch_prune.set_defaults(func=cmd_deepsearch_prune)
deepsearch_clear = deepsearch_sub.add_parser(
"clear", help="Delete every DeepSearch session and job row"
)
deepsearch_clear.set_defaults(func=cmd_deepsearch_clear)

46
devplacepy/cli/main.py Normal file
View File

@ -0,0 +1,46 @@
# retoor <retoor@molodetz.nl>
import argparse
import sys
from devplacepy.cli.roles import register_roles
from devplacepy.cli.apikeys import register_apikeys
from devplacepy.cli.tokens import register_tokens
from devplacepy.cli.news import register_news
from devplacepy.cli.attachments import register_attachments
from devplacepy.cli.devii import register_devii
from devplacepy.cli.jobs import register_jobs
from devplacepy.cli.backups import register_backups
from devplacepy.cli.containers import register_containers
from devplacepy.cli.migrate import register_migrate
def build_parser():
parser = argparse.ArgumentParser(description="DevPlace admin CLI")
sub = parser.add_subparsers(title="commands", dest="command")
register_roles(sub)
register_apikeys(sub)
register_tokens(sub)
register_news(sub)
register_attachments(sub)
register_devii(sub)
register_jobs(sub)
register_backups(sub)
register_containers(sub)
register_migrate(sub)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if hasattr(args, "func"):
args.func(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()

233
devplacepy/cli/migrate.py Normal file
View File

@ -0,0 +1,233 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def _crc32(path):
import zlib
crc = 0
with open(path, "rb") as handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
crc = zlib.crc32(chunk, crc)
return crc & 0xFFFFFFFF
def _migrate_file(source, dest, dry_run, report):
import os
import shutil
if not source.exists():
return
if source.resolve() == dest.resolve():
return
size = source.stat().st_size
if dest.exists():
if dest.stat().st_size == size and _crc32(dest) == _crc32(source):
report.append(("done", source, dest, size))
if not dry_run:
source.unlink()
return
report.append(("conflict", source, dest, size))
return
report.append(("move", source, dest, size))
if dry_run:
return
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".migrating")
shutil.copyfile(source, tmp)
with open(tmp, "rb") as handle:
os.fsync(handle.fileno())
if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source):
tmp.unlink(missing_ok=True)
raise RuntimeError(f"verification failed copying {source} -> {dest}")
os.replace(tmp, dest)
source.unlink()
def _prune_empty_dirs(root):
if not root.exists():
return
for path in sorted(root.rglob("*"), reverse=True):
if path.is_dir():
try:
path.rmdir()
except OSError:
pass
try:
root.rmdir()
except OSError:
pass
def _migrate_tree(source, dest, dry_run, report):
if not source.exists():
return
if source.resolve() == dest.resolve():
return
for child in sorted(source.rglob("*")):
if child.is_file():
_migrate_file(child, dest / child.relative_to(source), dry_run, report)
if not dry_run:
_prune_empty_dirs(source)
def _db_is_locked(path):
import sqlite3
try:
conn = sqlite3.connect(str(path), timeout=0.5)
try:
conn.execute("BEGIN IMMEDIATE")
conn.rollback()
return False
finally:
conn.close()
except sqlite3.OperationalError:
return True
def _checkpoint(path):
import sqlite3
conn = sqlite3.connect(str(path), timeout=5)
try:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.commit()
finally:
conn.close()
def _migrate_db(source, dest, dry_run, report):
if not source.exists():
return
if source.resolve() == dest.resolve():
return
if _db_is_locked(source):
raise RuntimeError(
f"{source} is locked - stop the app before running migrate-data"
)
if not dry_run:
_checkpoint(source)
_migrate_file(source, dest, dry_run, report)
for suffix in ("-wal", "-shm"):
_migrate_file(
source.with_name(source.name + suffix),
dest.with_name(dest.name + suffix),
dry_run,
report,
)
def cmd_emoji_sync(args):
from devplacepy.rendering import EMOJI_JS_PATH, write_emoji_module
count = write_emoji_module()
_audit_cli("cli.emoji.sync", f"CLI regenerated {count} emoji shortcodes", metadata={"count": count})
print(f"Wrote {count} emoji shortcodes to {EMOJI_JS_PATH}")
def cmd_migrate_data(args):
import os
from pathlib import Path
from collections import Counter
from devplacepy import config
base = config.BASE_DIR
home = Path.home()
dry = args.dry_run
report = []
config.ensure_data_dirs()
db_items = []
if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}":
db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db"))
else:
print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.")
if not os.environ.get("DEVII_TASKS_DB"):
db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB))
if not os.environ.get("DEVII_LESSONS_DB"):
db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB))
file_items = [
(base / name, config.KEYS_DIR / name)
for name in (
"notification-private.pem",
"notification-private.pkcs8.pem",
"notification-public.pem",
)
]
registry_dest = config.BOT_DIR / "article_registry.json"
registry_sources = [
path
for path in (
home / ".dpbot_article_registry.json",
base / ".dpbot_article_registry.json",
)
if path.exists()
]
registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True)
if registry_sources:
file_items.append((registry_sources[0], registry_dest))
for stale in registry_sources[1:]:
print(f"Leaving older duplicate registry untouched: {stale}")
legacy_var = base / "var"
tree_items = [
(base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR),
(home / ".devplace_bots", config.BOT_DIR),
]
for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"):
tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name]))
try:
for source, dest in db_items:
_migrate_db(source, dest, dry, report)
for source, dest in file_items:
_migrate_file(source, dest, dry, report)
for source, dest in tree_items:
_migrate_tree(source, dest, dry, report)
except RuntimeError as exc:
print(f"ERROR: {exc}")
sys.exit(1)
if not report:
print("Nothing to migrate; the data directory is already consolidated.")
return
for status, source, dest, size in report:
print(f" [{status}] {source} -> {dest} ({size} bytes)")
counts = Counter(status for status, *_ in report)
print()
print(
("Planned: " if dry else "Migrated: ")
+ ", ".join(f"{count} {status}" for status, count in sorted(counts.items()))
)
if any(status == "conflict" for status, *_ in report):
print(
"Conflicts left both source and destination untouched; resolve them by hand."
)
if dry:
print("Dry run - nothing changed. Re-run without --dry-run to apply.")
def register_migrate(subparsers):
subparsers.add_parser(
"emoji-sync",
help="Regenerate static/js/emoji-shortcodes.js from the emoji library",
).set_defaults(func=cmd_emoji_sync)
migrate = subparsers.add_parser(
"migrate-data",
help="Relocate legacy runtime files into the consolidated data/ directory",
)
migrate.add_argument(
"--dry-run",
action="store_true",
help="Print the source-to-destination plan without changing anything",
)
migrate.set_defaults(func=cmd_migrate_data)

53
devplacepy/cli/news.py Normal file
View File

@ -0,0 +1,53 @@
# retoor <retoor@molodetz.nl>
from devplacepy.utils import strip_html
from devplacepy.cli._shared import _audit_cli
def cmd_news_clear(args):
from devplacepy.database import db
deleted = {}
for table in ("news", "news_images", "news_sync"):
if table in db.tables:
count = db[table].count()
db[table].delete()
deleted[table] = count
print(f"Deleted {count} rows from '{table}'")
else:
print(f"Table '{table}' does not exist, skipping")
_audit_cli("cli.news.clear", "CLI cleared all news data", metadata={"deleted": deleted})
print("News data cleared")
def cmd_news_sanitize(args):
from devplacepy.database import db
if "news" not in db.tables:
print("News table does not exist")
return
news_table = db["news"]
updated = 0
for row in news_table.all():
desc = (strip_html(row.get("description", "") or ""))[:5000]
content = (strip_html(row.get("content", "") or ""))[:10000]
if desc != row.get("description", "") or content != row.get("content", ""):
news_table.update(
{"id": row["id"], "description": desc, "content": content}, ["id"]
)
updated += 1
_audit_cli("cli.news.sanitize", f"CLI sanitized {updated} news articles", metadata={"count": updated})
print(f"Sanitized {updated} news article(s)")
def register_news(subparsers):
news = subparsers.add_parser("news", help="News management")
news_sub = news.add_subparsers(title="action", dest="action")
news_clear = news_sub.add_parser(
"clear", help="Delete all news from local database"
)
news_clear.set_defaults(func=cmd_news_clear)
news_sanitize = news_sub.add_parser(
"sanitize", help="Strip HTML from all existing news descriptions and content"
)
news_sanitize.set_defaults(func=cmd_news_sanitize)

57
devplacepy/cli/roles.py Normal file
View File

@ -0,0 +1,57 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import get_table, invalidate_admins_cache
from devplacepy.cli._shared import _audit_cli
def cmd_role_get(args):
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
print(user.get("role", "member").lower())
def cmd_role_set(args):
role = args.role.lower()
if role not in ("member", "admin"):
print("Role must be 'member' or 'admin'")
sys.exit(1)
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
old_role = user.get("role")
users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"])
invalidate_admins_cache()
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.role.set",
f"CLI set role of user {args.username} from {old_role} to {role.capitalize()}",
metadata={"old": old_role, "new": role.capitalize()},
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(f"User '{args.username}' role set to '{role}'")
def register_roles(subparsers):
role = subparsers.add_parser("role", help="Manage user roles")
role_sub = role.add_subparsers(title="action", dest="action")
role_get = role_sub.add_parser("get", help="Get a user's role")
role_get.add_argument("username")
role_get.set_defaults(func=cmd_role_get)
role_set = role_sub.add_parser("set", help="Set a user's role")
role_set.add_argument("username")
role_set.add_argument("role", choices=["member", "admin"])
role_set.set_defaults(func=cmd_role_set)

125
devplacepy/cli/tokens.py Normal file
View File

@ -0,0 +1,125 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import get_table
from devplacepy.cli._shared import _audit_cli
def cmd_token_issue(args):
from devplacepy.services.access_tokens import issue_token
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
result = issue_token(user, label=args.label or "cli")
_audit_cli(
"cli.token.issue",
f"CLI issued access token for {args.username}",
target_type="user",
target_uid=user["uid"],
target_label=args.username,
metadata={"token_uid": result["uid"]},
)
print(result["access_token"])
def cmd_token_list(args):
from datetime import datetime, timezone
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
tokens = get_table("access_tokens")
now = datetime.now(timezone.utc)
found = False
for t in tokens.find(user_uid=user["uid"], deleted_at=None):
found = True
expires_at = t.get("expires_at", "")
try:
expires = datetime.fromisoformat(expires_at)
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
status = "expired" if expires < now else "active"
except (ValueError, TypeError):
status = "unknown"
label = t.get("label", "") or "-"
print(
f" uid={t['uid']} token={t['token'][:12]}... "
f"label={label} expires={expires_at} status={status}"
)
if not found:
print(f"No active tokens for '{args.username}'")
def cmd_token_revoke(args):
from devplacepy.services.access_tokens import revoke_token
ok = revoke_token(args.token_uid)
if not ok:
print(f"Token uid='{args.token_uid}' not found or already revoked")
sys.exit(1)
_audit_cli(
"cli.token.revoke",
f"CLI revoked access token uid={args.token_uid}",
metadata={"token_uid": args.token_uid},
)
print(f"Revoked token uid='{args.token_uid}'")
def cmd_token_revoke_all(args):
from devplacepy.services.access_tokens import revoke_all
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
count = revoke_all(user["uid"])
_audit_cli(
"cli.token.revoke_all",
f"CLI revoked all access tokens for {args.username}",
target_type="user",
target_uid=user["uid"],
target_label=args.username,
metadata={"count": count},
)
print(f"Revoked {count} token(s) for '{args.username}'")
def cmd_token_prune(args):
from devplacepy.services.access_tokens import prune_expired
count = prune_expired()
_audit_cli(
"cli.token.prune",
f"CLI pruned {count} expired access tokens",
metadata={"count": count},
)
print(f"Pruned {count} expired token(s)")
def register_tokens(subparsers):
token = subparsers.add_parser("token", help="Manage DevPlace access tokens")
token_sub = token.add_subparsers(title="action", dest="action")
token_issue = token_sub.add_parser("issue", help="Issue an access token for a user")
token_issue.add_argument("username")
token_issue.add_argument("--label", default="cli", help="Optional label for the token")
token_issue.set_defaults(func=cmd_token_issue)
token_list = token_sub.add_parser("list", help="List a user's active access tokens")
token_list.add_argument("username")
token_list.set_defaults(func=cmd_token_list)
token_revoke = token_sub.add_parser("revoke", help="Revoke a single access token by uid")
token_revoke.add_argument("token_uid")
token_revoke.set_defaults(func=cmd_token_revoke)
token_revoke_all = token_sub.add_parser("revoke-all", help="Revoke all access tokens for a user")
token_revoke_all.add_argument("username")
token_revoke_all.set_defaults(func=cmd_token_revoke_all)
token_prune = token_sub.add_parser("prune", help="Soft-delete all expired access tokens")
token_prune.set_defaults(func=cmd_token_prune)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,230 @@
# retoor <retoor@molodetz.nl>
from .core import dataset, logging, Path, or_, defaultdict, datetime, timedelta, timezone, TTLCache, DATABASE_URL, DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, ensure_data_dirs, logger, db
from .core import refresh_snapshot, _local_cache_versions, _cache_version_cache, _cache_state_ready, _ensure_cache_state, get_cache_version, bump_cache_version, sync_local_cache, _index, _drop_index, _uid_index, get_table, _in_clause, _now_iso
from .settings import _settings_cache, get_setting, get_int_setting, set_setting, clear_settings_cache, internal_gateway_key
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, get_primary_admin_uid, search_users_by_username
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, build_pagination
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post
from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage
from .seo_meta import SEO_META_TYPES, get_seo_metadata, get_seo_metadata_batch, has_fresh_seo_metadata, upsert_seo_metadata, mark_seo_metadata_stale
from .activity import record_activity, record_unique_activity, get_user_activity, _activity_cache, _ACTIVITY_TABLES, get_activity_calendar, _activity_level, get_first_activity_date, HEATMAP_WEEKS, get_activity_heatmap, get_activity_months, get_streaks
from .customization import CUSTOMIZATION_GLOBAL_SCOPE, CUSTOMIZATION_LANGS, _customizations_cache, _customization_key, CUSTOMIZATION_PREF_COLUMNS, get_customization_prefs, set_customization_pref, get_custom_overrides, get_custom_override, list_custom_overrides, set_custom_override, delete_custom_override
from .email import EMAIL_ACCOUNT_DEFAULTS, list_email_accounts, get_email_account, set_email_account, delete_email_account
from .notifications import NOTIFICATION_TYPES, NOTIFICATION_CHANNELS, _NOTIFICATION_CHANNEL_COLUMNS, _NOTIFICATION_CHANNEL_DEFAULTS, _NOTIFICATION_TYPE_KEYS, _notification_prefs_cache, _notification_default, get_notification_default, set_notification_default, _notification_overrides, notification_enabled, get_notification_prefs, set_notification_pref, reset_notification_prefs, mark_notifications_read_by_target
from .forks import record_fork, get_fork_parent, count_forks, soft_delete_fork_relations, delete_fork_relations
from .follows import get_follow_counts, get_follow_list, get_following_among
from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_session, get_deepsearch_session, add_deepsearch_message, get_deepsearch_messages, get_cached_deepsearch_url, upsert_deepsearch_url_cache
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids
from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_deleted_media
from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages
from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification
__all__ = [
"dataset",
"logging",
"Path",
"or_",
"defaultdict",
"datetime",
"timedelta",
"timezone",
"TTLCache",
"DATABASE_URL",
"DEFAULT_CORRECTION_PROMPT",
"DEFAULT_MODIFIER_PROMPT",
"INTERNAL_GATEWAY_URL",
"ensure_data_dirs",
"logger",
"db",
"refresh_snapshot",
"_local_cache_versions",
"_cache_version_cache",
"_cache_state_ready",
"_ensure_cache_state",
"get_cache_version",
"bump_cache_version",
"sync_local_cache",
"_index",
"_drop_index",
"_uid_index",
"get_table",
"_in_clause",
"_now_iso",
"_settings_cache",
"get_setting",
"get_int_setting",
"set_setting",
"clear_settings_cache",
"internal_gateway_key",
"get_users_by_uids",
"_admins_cache",
"invalidate_admins_cache",
"get_admin_uids",
"set_user_timezone",
"get_primary_admin_uid",
"search_users_by_username",
"_relations_cache",
"get_user_relations",
"get_blocked_uids",
"get_muted_uids",
"get_silenced_uids",
"invalidate_user_relations",
"PAGE_SIZE",
"paginate",
"interleave_by_author",
"paginate_diverse",
"get_user_post_count",
"build_pagination",
"SOFT_DELETE_TABLES",
"ensure_soft_delete_columns",
"soft_delete",
"soft_delete_in",
"restore",
"purge",
"list_deleted",
"count_deleted",
"restore_event",
"purge_event",
"_comment_count_cache",
"get_comment_counts_by_post_uids",
"get_post_counts_by_user_uids",
"get_vote_counts",
"get_user_votes",
"get_reactions_by_targets",
"get_user_bookmarks",
"get_polls_by_post_uids",
"get_poll_for_post",
"_add_usage",
"_get_usage",
"add_correction_usage",
"get_correction_usage",
"add_modifier_usage",
"get_modifier_usage",
"NEWS_USAGE_KEY",
"add_news_usage",
"get_news_usage",
"ISSUE_USAGE_KEY",
"add_issue_usage",
"get_issue_usage",
"SEO_USAGE_KEY",
"add_seo_usage",
"get_seo_usage",
"SEO_META_TYPES",
"get_seo_metadata",
"get_seo_metadata_batch",
"has_fresh_seo_metadata",
"upsert_seo_metadata",
"mark_seo_metadata_stale",
"record_activity",
"record_unique_activity",
"get_user_activity",
"_activity_cache",
"_ACTIVITY_TABLES",
"get_activity_calendar",
"_activity_level",
"get_first_activity_date",
"HEATMAP_WEEKS",
"get_activity_heatmap",
"get_activity_months",
"get_streaks",
"CUSTOMIZATION_GLOBAL_SCOPE",
"CUSTOMIZATION_LANGS",
"_customizations_cache",
"_customization_key",
"CUSTOMIZATION_PREF_COLUMNS",
"get_customization_prefs",
"set_customization_pref",
"get_custom_overrides",
"get_custom_override",
"list_custom_overrides",
"set_custom_override",
"delete_custom_override",
"EMAIL_ACCOUNT_DEFAULTS",
"list_email_accounts",
"get_email_account",
"set_email_account",
"delete_email_account",
"NOTIFICATION_TYPES",
"NOTIFICATION_CHANNELS",
"_NOTIFICATION_CHANNEL_COLUMNS",
"_NOTIFICATION_CHANNEL_DEFAULTS",
"_NOTIFICATION_TYPE_KEYS",
"_notification_prefs_cache",
"_notification_default",
"get_notification_default",
"set_notification_default",
"_notification_overrides",
"notification_enabled",
"get_notification_prefs",
"set_notification_pref",
"reset_notification_prefs",
"mark_notifications_read_by_target",
"record_fork",
"get_fork_parent",
"count_forks",
"soft_delete_fork_relations",
"delete_fork_relations",
"get_follow_counts",
"get_follow_list",
"get_following_among",
"_ds_now",
"create_deepsearch_session",
"update_deepsearch_session",
"get_deepsearch_session",
"add_deepsearch_message",
"get_deepsearch_messages",
"get_cached_deepsearch_url",
"upsert_deepsearch_url_cache",
"VOTABLE_TARGETS",
"STAR_TARGETS",
"_authors_cache",
"_ranked_authors",
"_rank_map",
"get_top_authors",
"get_leaderboard",
"get_user_rank",
"get_user_stars",
"update_target_stars",
"soft_delete_engagement",
"delete_engagement",
"get_target_owner_uid",
"_drop_blocked",
"_build_comment_items",
"load_comments",
"get_recent_comments_by_target_uids",
"get_recent_comments_by_post_uids",
"load_comments_by_target_uids",
"resolve_by_slug",
"resolve_object_url",
"get_uids_by_username_match",
"text_search_clause",
"get_daily_topic",
"get_featured_news",
"get_attachments",
"get_attachments_by_type",
"get_news_images_by_uids",
"delete_attachment_record",
"delete_attachments",
"_delete_attachment_file",
"get_user_media",
"get_deleted_media",
"_stats_cache",
"get_site_stats",
"_analytics_cache",
"get_platform_analytics",
"_gist_languages_cache",
"get_gist_languages",
"BUG_TABLE_RENAMES",
"migrate_bug_tables_to_issue_tables",
"init_db",
"_refresh_query_planner_stats",
"OLD_GATEWAY_URL",
"migrate_ai_gateway_settings",
"backfill_api_keys",
"_backfill_gamification",
]

View File

@ -0,0 +1,182 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, datetime, db, timedelta, timezone
def record_activity(user_uid: str, action: str) -> int:
if not user_uid or not action or "user_activity" not in db.tables:
return 0
now = datetime.now(timezone.utc).isoformat()
with db:
db.query(
"INSERT INTO user_activity (user_uid, action, count, first_at, last_at) "
"VALUES (:u, :a, 1, :now, :now) "
"ON CONFLICT(user_uid, action) DO UPDATE SET "
"count = count + 1, last_at = excluded.last_at",
u=user_uid,
a=action,
now=now,
)
rows = list(
db.query(
"SELECT count FROM user_activity WHERE user_uid = :u AND action = :a",
u=user_uid,
a=action,
)
)
return int(rows[0]["count"]) if rows else 0
def record_unique_activity(user_uid: str, action: str, target: str) -> int | None:
if not user_uid or not action or "user_activity_seen" not in db.tables:
return None
now = datetime.now(timezone.utc).isoformat()
with db:
db.query(
"INSERT OR IGNORE INTO user_activity_seen "
"(user_uid, action, target, created_at) VALUES (:u, :a, :t, :now)",
u=user_uid,
a=action,
t=str(target),
now=now,
)
changed = list(db.query("SELECT changes() AS c"))
if not changed or not changed[0]["c"]:
return None
rows = list(
db.query(
"SELECT COUNT(*) AS c FROM user_activity_seen "
"WHERE user_uid = :u AND action = :a",
u=user_uid,
a=action,
)
)
return int(rows[0]["c"]) if rows else 0
def get_user_activity(user_uid: str) -> dict:
if not user_uid or "user_activity" not in db.tables:
return {}
rows = db.query(
"SELECT action, count FROM user_activity WHERE user_uid = :u",
u=user_uid,
)
return {row["action"]: int(row["count"]) for row in rows}
_activity_cache = TTLCache(ttl=300, max_size=1000)
_ACTIVITY_TABLES = ("posts", "comments", "gists", "projects")
def get_activity_calendar(user_uid: str) -> dict:
cached = _activity_cache.get(user_uid)
if cached is not None:
return cached
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
calendar: dict[str, int] = {}
if sources:
cutoff = (datetime.now(timezone.utc) - timedelta(days=364)).date().isoformat()
union = " UNION ALL ".join(
f"SELECT created_at FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
for table in sources
)
rows = db.query(
f"SELECT date(created_at) AS day, COUNT(*) AS c FROM ({union}) WHERE date(created_at) >= :cutoff GROUP BY day",
u=user_uid,
cutoff=cutoff,
)
for row in rows:
if row["day"]:
calendar[row["day"]] = row["c"]
_activity_cache.set(user_uid, calendar)
return calendar
def _activity_level(count: int) -> int:
if count <= 0:
return 0
if count == 1:
return 1
if count <= 3:
return 2
if count <= 6:
return 3
return 4
def get_first_activity_date(user_uid: str):
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
if not sources:
return None
union = " UNION ALL ".join(
f"SELECT MIN(created_at) AS m FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
for table in sources
)
for row in db.query(f"SELECT MIN(m) AS first FROM ({union})", u=user_uid):
if row["first"]:
return datetime.fromisoformat(row["first"]).date()
return None
HEATMAP_WEEKS = 53
def get_activity_heatmap(user_uid: str) -> list:
calendar = get_activity_calendar(user_uid)
today = datetime.now(timezone.utc).date()
week_start = today - timedelta(days=today.weekday())
start = week_start - timedelta(weeks=HEATMAP_WEEKS - 1)
first = get_first_activity_date(user_uid)
if first:
first_week = first - timedelta(days=first.weekday())
if first_week > start:
start = first_week
weeks = []
for w in range(HEATMAP_WEEKS):
week = []
for d in range(7):
day = start + timedelta(days=w * 7 + d)
iso = day.isoformat()
count = calendar.get(iso, 0)
week.append({"date": iso, "count": count, "level": _activity_level(count)})
weeks.append(week)
return weeks
def get_activity_months(weeks: list) -> list:
if not weeks:
return []
last = len(weeks) - 1
labels = []
for i in range(6):
column = round(i * last / 5)
iso = weeks[column][0]["date"]
labels.append(datetime.fromisoformat(iso).strftime("%b"))
return labels
def get_streaks(user_uid: str) -> dict:
calendar = get_activity_calendar(user_uid)
if not calendar:
return {"current": 0, "longest": 0}
dates = sorted(datetime.fromisoformat(day).date() for day in calendar)
date_set = set(dates)
longest = 1
run = 1
for index in range(1, len(dates)):
if (dates[index] - dates[index - 1]).days == 1:
run += 1
else:
run = 1
longest = max(longest, run)
today = datetime.now(timezone.utc).date()
cursor = today
if today not in date_set and (today - timedelta(days=1)) in date_set:
cursor = today - timedelta(days=1)
current = 0
while cursor in date_set:
current += 1
cursor = cursor - timedelta(days=1)
return {"current": current, "longest": longest}

View File

@ -0,0 +1,153 @@
# retoor <retoor@molodetz.nl>
from .core import db, logger
from .users import get_users_by_uids
from .pagination import build_pagination
from .content import resolve_object_url
def get_attachments(resource_type: str, resource_uid: str) -> list:
if "attachments" not in db.tables:
return []
return list(
db["attachments"].find(
resource_type=resource_type,
resource_uid=resource_uid,
deleted_at=None,
order_by=["created_at"],
)
)
def get_attachments_by_type(resource_type: str, resource_uids: list) -> dict:
if not resource_uids or "attachments" not in db.tables:
return {}
rows = list(
db["attachments"].find(
db["attachments"].table.columns.resource_uid.in_(resource_uids),
db["attachments"].table.columns.deleted_at.is_(None),
resource_type=resource_type,
)
)
result = {}
for a in rows:
key = a["resource_uid"]
if key not in result:
result[key] = []
result[key].append(a)
return result
def get_news_images_by_uids(news_uids: list) -> dict:
if not news_uids or "news_images" not in db.tables:
return {}
images_table = db["news_images"]
if not images_table.has_column("news_uid"):
return {}
rows = images_table.find(
images_table.table.columns.news_uid.in_(news_uids),
images_table.table.columns.deleted_at.is_(None),
order_by=["uid"],
)
result = {}
for r in rows:
result.setdefault(r["news_uid"], r["url"])
return result
def delete_attachment_record(uid: str) -> None:
if "attachments" not in db.tables:
return
att = db["attachments"].find_one(uid=uid)
if att:
_delete_attachment_file(att)
db["attachments"].delete(id=att["id"])
def delete_attachments(resource_type: str, resource_uid: str) -> None:
if "attachments" not in db.tables:
return
for a in db["attachments"].find(
resource_type=resource_type, resource_uid=resource_uid
):
_delete_attachment_file(a)
db["attachments"].delete(resource_type=resource_type, resource_uid=resource_uid)
def _delete_attachment_file(att: dict) -> None:
from devplacepy.config import ATTACHMENTS_DIR
directory = att.get("directory", "")
stored_name = att.get("stored_name", "")
if not (directory and stored_name):
return
file_path = ATTACHMENTS_DIR / directory / stored_name
try:
file_path.unlink(missing_ok=True)
parent = file_path.parent
if parent.exists() and not any(parent.iterdir()):
parent.rmdir()
grandparent = parent.parent
if grandparent.exists() and not any(grandparent.iterdir()):
grandparent.rmdir()
except Exception as e:
logger.warning(f"Failed to delete attachment file {stored_name}: {e}")
def get_user_media(user_uid: str, page: int = 1, per_page: int = 24) -> tuple:
if "attachments" not in db.tables:
return [], build_pagination(page, 0, per_page)
from devplacepy.attachments import _row_to_attachment
total = list(
db.query(
"SELECT COUNT(*) AS n FROM attachments "
"WHERE user_uid=:u AND target_type != '' AND deleted_at IS NULL",
u=user_uid,
)
)[0]["n"]
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = db.query(
"SELECT * FROM attachments "
"WHERE user_uid=:u AND target_type != '' AND deleted_at IS NULL "
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
u=user_uid,
limit=pagination["per_page"],
offset=offset,
)
items = []
for row in rows:
item = _row_to_attachment(row)
item["target_url"] = resolve_object_url(item["target_type"], item["target_uid"])
items.append(item)
return items, pagination
def get_deleted_media(page: int = 1, per_page: int = 24) -> tuple:
if "attachments" not in db.tables:
return [], build_pagination(page, 0, per_page)
from devplacepy.attachments import _row_to_attachment
total = list(
db.query("SELECT COUNT(*) AS n FROM attachments WHERE deleted_at IS NOT NULL")
)[0]["n"]
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = db.query(
"SELECT * FROM attachments WHERE deleted_at IS NOT NULL "
"ORDER BY deleted_at DESC LIMIT :limit OFFSET :offset",
limit=pagination["per_page"],
offset=offset,
)
rows = list(rows)
uploaders = get_users_by_uids([row.get("user_uid") for row in rows])
items = []
for row in rows:
item = _row_to_attachment(row)
item["target_url"] = resolve_object_url(item["target_type"], item["target_uid"])
item["deleted_at"] = row.get("deleted_at", "")
uploader = uploaders.get(row.get("user_uid"))
item["uploader"] = uploader["username"] if uploader else "unknown"
items.append(item)
return items, pagination

View File

@ -0,0 +1,155 @@
# retoor <retoor@molodetz.nl>
from .core import _in_clause, db, defaultdict
from .users import get_users_by_uids
from .relations import get_blocked_uids
from .engagement import get_reactions_by_targets, get_user_votes, get_vote_counts
def _drop_blocked(raw, user):
if not user:
return raw
blocked = get_blocked_uids(user["uid"])
if not blocked:
return raw
return [c for c in raw if c["user_uid"] not in blocked]
def _build_comment_items(raw, user=None):
uids = [c["user_uid"] for c in raw]
cids = [c["uid"] for c in raw]
users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids)
user_votes = get_user_votes(user["uid"], cids) if user else {}
reactions = get_reactions_by_targets("comment", cids, user)
from devplacepy.utils import time_ago
from devplacepy.attachments import get_attachments_batch as _gab
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
items = {}
for c in raw:
items[c["uid"]] = {
"comment": c,
"author": users.get(c["user_uid"]),
"time_ago": time_ago(c["created_at"]),
"votes": {"up": ups.get(c["uid"], 0), "down": downs.get(c["uid"], 0)},
"my_vote": user_votes.get(c["uid"], 0),
"children": [],
"attachments": atts_map.get(c["uid"], []),
"reactions": reactions.get(c["uid"], {"counts": {}, "mine": []}),
}
return items
def load_comments(target_type, target_uid, user=None):
if "comments" not in db.tables:
return []
comments_table = db["comments"]
raw = list(
comments_table.find(
target_type=target_type,
target_uid=target_uid,
deleted_at=None,
order_by=["created_at"],
)
)
if not raw and target_type == "post":
raw = list(
comments_table.find(
post_uid=target_uid, deleted_at=None, order_by=["created_at"]
)
)
raw = _drop_blocked(raw, user)
if not raw:
return []
cmap = _build_comment_items(raw, user)
top = []
for item in cmap.values():
parent = item["comment"].get("parent_uid")
if parent and parent in cmap:
cmap[parent]["children"].append(item)
else:
top.append(item)
return top
def get_recent_comments_by_target_uids(target_type, target_uids, limit=3, user=None):
if not target_uids or "comments" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
params["lim"] = limit
raw = list(
db.query(
f"SELECT * FROM ("
f" SELECT *, ROW_NUMBER() OVER ("
f" PARTITION BY target_uid ORDER BY created_at DESC, id DESC"
f" ) AS rn FROM comments"
f" WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL"
f") WHERE rn <= :lim ORDER BY target_uid, created_at ASC",
**params,
)
)
raw = _drop_blocked(raw, user)
if not raw:
return {}
items = _build_comment_items(raw, user)
by_target = defaultdict(list)
for c in raw:
by_target[c["target_uid"]].append(c)
result = {}
for target_uid, group in by_target.items():
in_group = {c["uid"] for c in group}
top = []
for c in group:
item = items[c["uid"]]
item["children"] = []
for c in group:
item = items[c["uid"]]
parent = c.get("parent_uid")
if parent and parent in in_group:
items[parent]["children"].append(item)
else:
top.append(item)
result[target_uid] = top
return result
def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
return get_recent_comments_by_target_uids("post", post_uids, limit, user)
def load_comments_by_target_uids(target_type, target_uids, user=None):
if not target_uids or "comments" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
raw = list(
db.query(
f"SELECT * FROM comments WHERE target_type=:tt AND target_uid IN ({placeholders}) ORDER BY created_at",
**params,
)
)
raw = _drop_blocked(raw, user)
if not raw:
return {}
from collections import defaultdict
by_uid = defaultdict(list)
for c in raw:
by_uid[c["target_uid"]].append(c)
result = {}
for uid in target_uids:
group = by_uid.get(uid, [])
if not group:
result[uid] = []
continue
cmap = _build_comment_items(group, user)
tree = []
for item in cmap.values():
parent = item["comment"].get("parent_uid")
if parent and parent in cmap:
cmap[parent]["children"].append(item)
else:
tree.append(item)
result[uid] = tree
return result

View File

@ -0,0 +1,124 @@
# retoor <retoor@molodetz.nl>
from .core import db, get_table, or_
def resolve_by_slug(table, slug, include_deleted=False):
has_soft_delete = table.has_column("deleted_at")
flt = {} if include_deleted or not has_soft_delete else {"deleted_at": None}
entry = table.find_one(slug=slug, **flt)
if not entry:
entry = table.find_one(uid=slug, **flt)
return entry
def resolve_object_url(target_type: str, target_uid: str) -> str:
if target_type == "post":
post = resolve_by_slug(get_table("posts"), target_uid)
return f"/posts/{post['slug'] or post['uid']}" if post else "/feed"
if target_type == "project":
project = resolve_by_slug(get_table("projects"), target_uid)
return (
f"/projects/{project['slug'] or project['uid']}" if project else "/projects"
)
if target_type == "news":
article = resolve_by_slug(get_table("news"), target_uid)
if article:
return f"/news/{article.get('slug', '') or article['uid']}"
return "/news"
if target_type == "issue":
return f"/issues?highlight={target_uid}"
if target_type == "gist":
gist = resolve_by_slug(get_table("gists"), target_uid)
return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists"
if target_type == "comment":
comment = get_table("comments").find_one(uid=target_uid, deleted_at=None)
if not comment:
return "/feed"
parent_url = resolve_object_url(
comment.get("target_type", "post"),
comment.get("target_uid") or comment.get("post_uid", ""),
)
return f"{parent_url}#comment-{target_uid}"
return "/feed"
def get_uids_by_username_match(search, limit=200):
term = (search or "").strip()
if not term or "users" not in db.tables:
return []
rows = db.query(
"SELECT uid FROM users WHERE username LIKE :q LIMIT :limit",
q=f"%{term}%",
limit=limit,
)
return [row["uid"] for row in rows]
def text_search_clause(
table, search, fields=("title", "description"), author_field=None
):
if not search or not search.strip() or not table.exists:
return None
columns = table.table.columns
like = f"%{search.strip()}%"
matches = [columns[field].ilike(like) for field in fields if field in columns]
if author_field and author_field in columns:
author_uids = get_uids_by_username_match(search)
if author_uids:
matches.append(columns[author_field].in_(author_uids))
return or_(*matches) if matches else None
def get_daily_topic():
if "news" in db.tables:
article = db["news"].find_one(
status="published", deleted_at=None, order_by=["-synced_at"]
)
if article:
desc = (article.get("description") or "")[:200] or (
article.get("content") or ""
)[:200]
return {
"title": article.get("title", ""),
"summary": desc,
"slug": article.get("slug", ""),
"url": article.get("url", ""),
"image_url": article.get("image_url", ""),
}
return {
"title": "Welcome to DevPlace",
"summary": "Stay tuned for the latest dev news.",
}
def get_featured_news(limit=5):
if "news" not in db.tables:
return []
from devplacepy.utils import time_ago
rows = list(
db["news"].find(
show_on_landing=1, deleted_at=None, order_by=["-synced_at"], _limit=limit
)
)
articles = []
for article in rows:
summary = (article.get("description") or "")[:120] or (
article.get("content") or ""
)[:120]
articles.append(
{
"title": article.get("title", ""),
"summary": summary,
"slug": article.get("slug", ""),
"url": article.get("url", ""),
"source_name": article.get("source_name", ""),
"featured": article.get("featured", 0),
"image_url": article.get("image_url", "") or "",
"time_ago": time_ago(article["synced_at"])
if article.get("synced_at")
else "",
}
)
return articles

166
devplacepy/database/core.py Normal file
View File

@ -0,0 +1,166 @@
# retoor <retoor@molodetz.nl>
import dataset
import logging
from pathlib import Path
from sqlalchemy import or_
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import (
DATABASE_URL,
DEFAULT_CORRECTION_PROMPT,
DEFAULT_MODIFIER_PROMPT,
INTERNAL_GATEWAY_URL,
ensure_data_dirs,
)
logger = logging.getLogger(__name__)
ensure_data_dirs()
if DATABASE_URL.startswith("sqlite:///"):
_db_file = DATABASE_URL[len("sqlite:///") :]
if _db_file and _db_file != ":memory:":
Path(_db_file).parent.mkdir(parents=True, exist_ok=True)
db = dataset.connect(
DATABASE_URL,
engine_kwargs={
"connect_args": {
"timeout": 30,
"check_same_thread": False,
},
},
on_connect_statements=[
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=30000",
"PRAGMA cache_size=-8000",
"PRAGMA temp_store=MEMORY",
"PRAGMA mmap_size=268435456",
],
)
def refresh_snapshot() -> None:
connection = db.executable
if connection.in_transaction() and not db.in_transaction:
connection.commit()
_local_cache_versions: dict = {}
_cache_version_cache = TTLCache(ttl=1)
_cache_state_ready = False
def _ensure_cache_state() -> None:
global _cache_state_ready
if _cache_state_ready:
return
db.query(
"CREATE TABLE IF NOT EXISTS cache_state "
"(name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)"
)
_cache_state_ready = True
def get_cache_version(name: str) -> int:
cached = _cache_version_cache.get(name)
if cached is not None:
return cached
try:
_ensure_cache_state()
row = next(
iter(
db.query(
"SELECT version FROM cache_state WHERE name = :name", name=name
)
),
None,
)
version = int(row["version"]) if row else 0
except Exception as e:
logger.warning(f"Could not read cache version {name}: {e}")
return 0
_cache_version_cache.set(name, version)
return version
def bump_cache_version(name: str) -> None:
try:
_ensure_cache_state()
db.query(
"INSERT OR IGNORE INTO cache_state (name, version) VALUES (:name, 0)",
name=name,
)
db.query(
"UPDATE cache_state SET version = version + 1 WHERE name = :name", name=name
)
connection = db.executable
if connection.in_transaction():
connection.commit()
_cache_version_cache.pop(name)
except Exception as e:
logger.warning(f"Could not bump cache version {name}: {e}")
def sync_local_cache(name: str, cache) -> None:
current = get_cache_version(name)
if name not in _local_cache_versions:
_local_cache_versions[name] = current
return
if _local_cache_versions[name] != current:
cache.clear()
_local_cache_versions[name] = current
def _index(db, table, name, columns, *, where=None, unique=False):
try:
if table in db.tables:
cols = ", ".join(columns)
kind = "UNIQUE INDEX" if unique else "INDEX"
clause = f" WHERE {where}" if where else ""
with db:
db.query(
f"CREATE {kind} IF NOT EXISTS {name} ON {table} ({cols}){clause}"
)
except Exception as e:
logger.warning(f"Could not create index {name} on {table}: {e}")
def _drop_index(db, name):
try:
with db:
db.query(f"DROP INDEX IF EXISTS {name}")
except Exception as e:
logger.warning(f"Could not drop index {name}: {e}")
def _uid_index(db, table):
if table not in db.tables or "uid" not in get_table(table).columns:
return
name = f"idx_{table}_uid"
try:
with db:
db.query(f"CREATE UNIQUE INDEX IF NOT EXISTS {name} ON {table} (uid)")
except Exception as e:
logger.warning(f"Unique uid index on {table} failed ({e}); using non-unique")
_index(db, table, name, ["uid"])
def get_table(name):
return db[name]
def _in_clause(uids, prefix="p"):
placeholders = ", ".join(f":{prefix}{i}" for i in range(len(uids)))
params = {f"{prefix}{i}": uid for i, uid in enumerate(uids)}
return placeholders, params
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()

View File

@ -0,0 +1,167 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, datetime, db, get_table, sync_local_cache, timezone
from .soft_delete import soft_delete
CUSTOMIZATION_GLOBAL_SCOPE = "global"
CUSTOMIZATION_LANGS = ("css", "js")
_customizations_cache = TTLCache(ttl=300, max_size=100)
def _customization_key(owner_kind: str, owner_id: str, page_type: str) -> str:
return f"{owner_kind}\x1f{owner_id}\x1f{page_type}"
CUSTOMIZATION_PREF_COLUMNS = {
"global": "cust_disable_global",
"pagetype": "cust_disable_pagetype",
}
def get_customization_prefs(owner_kind: str, owner_id: str) -> dict:
if owner_kind != "user" or "users" not in db.tables:
return {"disable_global": False, "disable_pagetype": False}
user = db["users"].find_one(uid=owner_id)
if user is None:
return {"disable_global": False, "disable_pagetype": False}
return {
"disable_global": bool(user.get("cust_disable_global", 0)),
"disable_pagetype": bool(user.get("cust_disable_pagetype", 0)),
}
def set_customization_pref(owner_id: str, category: str, disabled: bool) -> None:
column = CUSTOMIZATION_PREF_COLUMNS.get(category)
if column is None:
raise ValueError(f"Unknown customization category: {category}")
from devplacepy.utils import clear_user_cache
get_table("users").update(
{"uid": owner_id, column: 1 if disabled else 0}, ["uid"]
)
clear_user_cache(owner_id)
bump_cache_version("customizations")
def get_custom_overrides(owner_kind: str, owner_id: str, page_type: str) -> dict:
sync_local_cache("customizations", _customizations_cache)
key = _customization_key(owner_kind, owner_id, page_type)
cached = _customizations_cache.get(key)
if cached is not None:
return cached
result = {"css": "", "js": ""}
if "user_customizations" in db.tables:
prefs = get_customization_prefs(owner_kind, owner_id)
scopes = (CUSTOMIZATION_GLOBAL_SCOPE, page_type)
rows = db["user_customizations"].find(
owner_kind=owner_kind,
owner_id=owner_id,
enabled=1,
deleted_at=None,
)
pieces: dict[str, dict[str, str]] = {lang: {} for lang in CUSTOMIZATION_LANGS}
for row in rows:
lang = row.get("lang")
scope = row.get("scope")
if lang not in pieces or scope not in scopes:
continue
if scope == CUSTOMIZATION_GLOBAL_SCOPE and prefs["disable_global"]:
continue
if scope != CUSTOMIZATION_GLOBAL_SCOPE and prefs["disable_pagetype"]:
continue
pieces[lang][scope] = row.get("code") or ""
for lang in CUSTOMIZATION_LANGS:
ordered = [pieces[lang][scope] for scope in scopes if scope in pieces[lang]]
result[lang] = "\n".join(part for part in ordered if part.strip())
_customizations_cache.set(key, result)
return result
def get_custom_override(
owner_kind: str, owner_id: str, scope: str, lang: str
) -> dict | None:
if "user_customizations" not in db.tables:
return None
return db["user_customizations"].find_one(
owner_kind=owner_kind,
owner_id=owner_id,
scope=scope,
lang=lang,
deleted_at=None,
)
def list_custom_overrides(owner_kind: str, owner_id: str) -> list:
if "user_customizations" not in db.tables:
return []
return list(
db["user_customizations"].find(
owner_kind=owner_kind, owner_id=owner_id, deleted_at=None
)
)
def set_custom_override(
owner_kind: str, owner_id: str, scope: str, lang: str, code: str
) -> dict:
from devplacepy.utils import generate_uid
table = get_table("user_customizations")
now = datetime.now(timezone.utc).isoformat()
existing = table.find_one(
owner_kind=owner_kind, owner_id=owner_id, scope=scope, lang=lang
)
if existing:
record = {
"id": existing["id"],
"code": code,
"enabled": 1,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.update(record, ["id"])
result = {**existing, **record}
else:
result = {
"uid": generate_uid(),
"owner_kind": owner_kind,
"owner_id": owner_id,
"scope": scope,
"lang": lang,
"code": code,
"enabled": 1,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.insert(result)
bump_cache_version("customizations")
return result
def delete_custom_override(
owner_kind: str,
owner_id: str,
scope: str | None = None,
lang: str | None = None,
deleted_by: str | None = None,
) -> int:
if "user_customizations" not in db.tables:
return 0
criteria: dict = {"owner_kind": owner_kind, "owner_id": owner_id}
if scope is not None:
criteria["scope"] = scope
if lang is not None:
criteria["lang"] = lang
count = soft_delete(
"user_customizations", deleted_by or f"{owner_kind}:{owner_id}", **criteria
)
bump_cache_version("customizations")
return int(count)

View File

@ -0,0 +1,119 @@
# retoor <retoor@molodetz.nl>
from .core import datetime, db, get_table, timezone
def _ds_now() -> str:
return datetime.now(timezone.utc).isoformat()
def create_deepsearch_session(
uid: str,
owner_kind: str,
owner_id: str,
query: str,
depth: int,
max_pages: int,
collection: str,
) -> None:
get_table("deepsearch_sessions").insert(
{
"uid": uid,
"owner_kind": owner_kind,
"owner_id": owner_id,
"query": query,
"status": "pending",
"depth": depth,
"max_pages": max_pages,
"score": 0,
"confidence": 0.0,
"source_diversity": 0.0,
"page_count": 0,
"chunk_count": 0,
"collection": collection,
"summary": "",
"created_at": _ds_now(),
"completed_at": "",
"deleted_at": None,
"deleted_by": None,
}
)
def update_deepsearch_session(uid: str, fields: dict) -> None:
if "deepsearch_sessions" not in db.tables:
return
payload = dict(fields)
payload["uid"] = uid
get_table("deepsearch_sessions").update(payload, ["uid"])
def get_deepsearch_session(uid: str) -> dict | None:
if "deepsearch_sessions" not in db.tables:
return None
return get_table("deepsearch_sessions").find_one(uid=uid, deleted_at=None)
def add_deepsearch_message(
uid: str, session_uid: str, role: str, content: str, citations: str = ""
) -> None:
get_table("deepsearch_messages").insert(
{
"uid": uid,
"session_uid": session_uid,
"role": role,
"content": content,
"citations": citations,
"created_at": _ds_now(),
"deleted_at": None,
"deleted_by": None,
}
)
def get_deepsearch_messages(session_uid: str, limit: int = 50) -> list[dict]:
if "deepsearch_messages" not in db.tables:
return []
return list(
get_table("deepsearch_messages").find(
session_uid=session_uid,
deleted_at=None,
order_by=["created_at"],
_limit=limit,
)
)
def get_cached_deepsearch_url(url_hash: str) -> dict | None:
if "deepsearch_url_cache" not in db.tables:
return None
return get_table("deepsearch_url_cache").find_one(url_hash=url_hash)
def upsert_deepsearch_url_cache(
url_hash: str,
url: str,
title: str,
content_hash: str,
status: int,
byte_size: int,
) -> None:
table = get_table("deepsearch_url_cache")
existing = table.find_one(url_hash=url_hash)
row = {
"url_hash": url_hash,
"url": url,
"title": title,
"content_hash": content_hash,
"status": status,
"byte_size": byte_size,
"fetched_at": _ds_now(),
}
if existing:
row["uid"] = existing["uid"]
table.update(row, ["uid"])
else:
from devplacepy.utils import generate_uid
row["uid"] = generate_uid()
table.insert(row)

View File

@ -0,0 +1,95 @@
# retoor <retoor@molodetz.nl>
from .core import datetime, db, get_table, timezone
from .soft_delete import soft_delete
EMAIL_ACCOUNT_DEFAULTS: dict[str, object] = {
"imap_host": "",
"imap_port": 993,
"imap_ssl": 1,
"imap_starttls": 0,
"smtp_host": "",
"smtp_port": 587,
"smtp_ssl": 0,
"smtp_starttls": 1,
"username": "",
"password": "",
"from_address": "",
"from_name": "",
}
def list_email_accounts(owner_kind: str, owner_id: str) -> list:
if "email_accounts" not in db.tables:
return []
return list(
db["email_accounts"].find(
owner_kind=owner_kind, owner_id=owner_id, deleted_at=None
)
)
def get_email_account(owner_kind: str, owner_id: str, label: str) -> dict | None:
if "email_accounts" not in db.tables:
return None
return db["email_accounts"].find_one(
owner_kind=owner_kind, owner_id=owner_id, label=label, deleted_at=None
)
def set_email_account(
owner_kind: str, owner_id: str, label: str, fields: dict
) -> dict:
from devplacepy.utils import generate_uid
table = get_table("email_accounts")
now = datetime.now(timezone.utc).isoformat()
existing = table.find_one(owner_kind=owner_kind, owner_id=owner_id, label=label)
values = {**EMAIL_ACCOUNT_DEFAULTS, **(existing or {}), **fields}
if not values.get("from_address"):
values["from_address"] = values.get("username") or ""
record = {
key: values.get(key, default)
for key, default in EMAIL_ACCOUNT_DEFAULTS.items()
}
if existing:
record.update(
{
"id": existing["id"],
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
table.update(record, ["id"])
result = {**existing, **record}
else:
result = {
"uid": generate_uid(),
"owner_kind": owner_kind,
"owner_id": owner_id,
"label": label,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
**record,
}
table.insert(result)
return result
def delete_email_account(
owner_kind: str, owner_id: str, label: str, deleted_by: str | None = None
) -> int:
if "email_accounts" not in db.tables:
return 0
count = soft_delete(
"email_accounts",
deleted_by or f"{owner_kind}:{owner_id}",
owner_kind=owner_kind,
owner_id=owner_id,
label=label,
)
return int(count)

View File

@ -0,0 +1,187 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, _in_clause, db, defaultdict
_comment_count_cache = TTLCache(ttl=15, max_size=10000)
def get_comment_counts_by_post_uids(post_uids):
if not post_uids or "comments" not in db.tables:
return {}
result = {}
misses = []
for uid in post_uids:
cached = _comment_count_cache.get(uid)
if cached is None:
misses.append(uid)
else:
result[uid] = cached
if misses:
placeholders, params = _in_clause(misses)
rows = db.query(
f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid",
**params,
)
fetched = {r["target_uid"]: r["c"] for r in rows}
for uid in misses:
count = fetched.get(uid, 0)
_comment_count_cache.set(uid, count)
result[uid] = count
return result
def get_post_counts_by_user_uids(user_uids):
if not user_uids or "posts" not in db.tables:
return {}
placeholders, params = _in_clause(user_uids)
rows = db.query(
f"SELECT user_uid, COUNT(*) as c FROM posts WHERE user_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY user_uid",
**params,
)
return {r["user_uid"]: r["c"] for r in rows}
def get_vote_counts(target_uids):
if not target_uids or "votes" not in db.tables:
return {}, {}
placeholders, params = _in_clause(target_uids)
rows = db.query(
f"SELECT target_uid, value, COUNT(*) as c FROM votes WHERE target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid, value",
**params,
)
ups = {}
downs = {}
for r in rows:
if r["value"] == 1:
ups[r["target_uid"]] = r["c"]
else:
downs[r["target_uid"]] = r["c"]
return ups, downs
def get_user_votes(user_uid, target_uids):
if not user_uid or not target_uids or "votes" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["uid"] = user_uid
rows = db.query(
f"SELECT target_uid, value FROM votes WHERE user_uid = :uid AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {r["target_uid"]: r["value"] for r in rows}
def get_reactions_by_targets(target_type, target_uids, user=None):
if not target_uids or "reactions" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
rows = db.query(
f"SELECT target_uid, emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid, emoji",
**params,
)
counts = defaultdict(dict)
for row in rows:
counts[row["target_uid"]][row["emoji"]] = row["c"]
mine = defaultdict(list)
if user:
placeholders, params = _in_clause(target_uids, prefix="m")
params["tt"] = target_type
params["u"] = user["uid"]
for row in db.query(
f"SELECT target_uid, emoji FROM reactions WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
):
mine[row["target_uid"]].append(row["emoji"])
result = {}
for uid in target_uids:
result[uid] = {
"counts": dict(counts.get(uid, {})),
"mine": list(mine.get(uid, [])),
}
return result
def get_user_bookmarks(user_uid, target_type, target_uids):
if not user_uid or not target_uids or "bookmarks" not in db.tables:
return set()
placeholders, params = _in_clause(target_uids)
params["u"] = user_uid
params["tt"] = target_type
rows = db.query(
f"SELECT target_uid FROM bookmarks WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {row["target_uid"] for row in rows}
def get_polls_by_post_uids(post_uids, user=None):
if not post_uids or "polls" not in db.tables:
return {}
placeholders, params = _in_clause(post_uids)
polls = list(
db.query(
f"SELECT * FROM polls WHERE post_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
)
if not polls:
return {}
poll_uids = [poll["uid"] for poll in polls]
option_placeholders, option_params = _in_clause(poll_uids, prefix="o")
options = list(
db.query(
f"SELECT * FROM poll_options WHERE poll_uid IN ({option_placeholders}) AND deleted_at IS NULL ORDER BY position",
**option_params,
)
)
counts = defaultdict(dict)
totals = defaultdict(int)
if "poll_votes" in db.tables:
vote_placeholders, vote_params = _in_clause(poll_uids, prefix="v")
for row in db.query(
f"SELECT poll_uid, option_uid, COUNT(*) as c FROM poll_votes WHERE poll_uid IN ({vote_placeholders}) AND deleted_at IS NULL GROUP BY poll_uid, option_uid",
**vote_params,
):
counts[row["poll_uid"]][row["option_uid"]] = row["c"]
totals[row["poll_uid"]] += row["c"]
user_choice = {}
if user and "poll_votes" in db.tables:
placeholders, params = _in_clause(poll_uids, prefix="m")
params["u"] = user["uid"]
for row in db.query(
f"SELECT poll_uid, option_uid FROM poll_votes WHERE user_uid=:u AND poll_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
):
user_choice[row["poll_uid"]] = row["option_uid"]
options_by_poll = defaultdict(list)
for option in options:
options_by_poll[option["poll_uid"]].append(option)
result = {}
for poll in polls:
poll_uid = poll["uid"]
total = totals.get(poll_uid, 0)
rendered = []
for option in options_by_poll.get(poll_uid, []):
count = counts.get(poll_uid, {}).get(option["uid"], 0)
rendered.append(
{
"uid": option["uid"],
"label": option["label"],
"count": count,
"pct": round(count * 100 / total) if total else 0,
}
)
result[poll["post_uid"]] = {
"uid": poll_uid,
"question": poll["question"],
"options": rendered,
"total": total,
"my_choice": user_choice.get(poll_uid),
}
return result
def get_poll_for_post(post_uid, user=None):
return get_polls_by_post_uids([post_uid], user).get(post_uid)

View File

@ -0,0 +1,63 @@
# retoor <retoor@molodetz.nl>
from .core import _in_clause, db, get_table
from .users import get_users_by_uids
from .pagination import build_pagination
def get_follow_counts(user_uid: str) -> dict:
if "follows" not in db.tables:
return {"followers": 0, "following": 0}
follows = get_table("follows")
return {
"followers": follows.count(following_uid=user_uid, deleted_at=None),
"following": follows.count(follower_uid=user_uid, deleted_at=None),
}
def get_follow_list(
user_uid: str, mode: str, page: int = 1, per_page: int = 25
) -> tuple:
if "follows" not in db.tables:
return [], build_pagination(page, 0, per_page)
follows = get_table("follows")
key = "following_uid" if mode == "followers" else "follower_uid"
other = "follower_uid" if mode == "followers" else "following_uid"
total = follows.count(deleted_at=None, **{key: user_uid})
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = list(
follows.find(
order_by=["-created_at"],
_limit=pagination["per_page"],
_offset=offset,
deleted_at=None,
**{key: user_uid},
)
)
users_map = get_users_by_uids([row[other] for row in rows])
people = []
for row in rows:
person = users_map.get(row[other])
if person:
people.append(
{
"uid": person["uid"],
"username": person["username"],
"bio": (person.get("bio") or "")[:140],
"followed_at": row.get("created_at"),
}
)
return people, pagination
def get_following_among(follower_uid: str, target_uids: list) -> set:
if not follower_uid or not target_uids or "follows" not in db.tables:
return set()
placeholders, params = _in_clause(target_uids)
params["f"] = follower_uid
rows = db.query(
f"SELECT following_uid FROM follows WHERE follower_uid = :f AND following_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {row["following_uid"] for row in rows}

View File

@ -0,0 +1,59 @@
# retoor <retoor@molodetz.nl>
from .core import _now_iso, datetime, db, get_table, timezone
from .soft_delete import soft_delete
def record_fork(
source_project_uid: str, forked_project_uid: str, forked_by_uid: str
) -> None:
from devplacepy.utils import generate_uid
get_table("project_forks").insert(
{
"uid": generate_uid(),
"source_project_uid": source_project_uid,
"forked_project_uid": forked_project_uid,
"forked_by_uid": forked_by_uid,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
def get_fork_parent(forked_project_uid: str) -> dict | None:
if "project_forks" not in db.tables:
return None
relation = get_table("project_forks").find_one(
forked_project_uid=forked_project_uid, deleted_at=None
)
if not relation:
return None
return get_table("projects").find_one(
uid=relation["source_project_uid"], deleted_at=None
)
def count_forks(source_project_uid: str) -> int:
if "project_forks" not in db.tables:
return 0
return get_table("project_forks").count(
source_project_uid=source_project_uid, deleted_at=None
)
def soft_delete_fork_relations(project_uid: str, deleted_by: str) -> None:
if "project_forks" not in db.tables:
return
stamp = _now_iso()
soft_delete("project_forks", deleted_by, stamp=stamp, forked_project_uid=project_uid)
soft_delete("project_forks", deleted_by, stamp=stamp, source_project_uid=project_uid)
def delete_fork_relations(project_uid: str) -> None:
if "project_forks" not in db.tables:
return
forks = get_table("project_forks")
forks.delete(forked_project_uid=project_uid)
forks.delete(source_project_uid=project_uid)

View File

@ -0,0 +1,198 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, datetime, db, get_table, sync_local_cache, timezone
from .settings import get_int_setting, set_setting
from .soft_delete import soft_delete
NOTIFICATION_TYPES = [
{"key": "comment", "label": "Comments", "description": "Someone comments on your post"},
{"key": "reply", "label": "Replies", "description": "Someone replies to your comment"},
{"key": "mention", "label": "Mentions", "description": "Someone mentions you with @username"},
{"key": "vote", "label": "Upvotes", "description": "Someone ++'d your content"},
{"key": "follow", "label": "Followers", "description": "Someone starts following you"},
{"key": "message", "label": "Direct messages", "description": "Someone sends you a message"},
{"key": "badge", "label": "Badges", "description": "You earn a badge"},
{"key": "level", "label": "Level-ups", "description": "You reach a new level"},
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
{"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"},
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
]
NOTIFICATION_CHANNELS = ("in_app", "push", "telegram")
_NOTIFICATION_CHANNEL_COLUMNS = {
"in_app": "in_app_enabled",
"push": "push_enabled",
"telegram": "telegram_enabled",
}
_NOTIFICATION_CHANNEL_DEFAULTS = {"in_app": 1, "push": 1, "telegram": 0}
_NOTIFICATION_TYPE_KEYS = {entry["key"] for entry in NOTIFICATION_TYPES}
_notification_prefs_cache = TTLCache(ttl=300, max_size=500)
def _notification_default(notification_type: str, channel: str) -> bool:
fallback = _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1)
return get_int_setting(f"notif_default_{notification_type}_{channel}", fallback) != 0
def get_notification_default(notification_type: str, channel: str) -> bool:
return _notification_default(notification_type, channel)
def set_notification_default(
notification_type: str, channel: str, enabled: bool
) -> None:
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
raise ValueError(f"Unknown notification channel: {channel}")
set_setting(f"notif_default_{notification_type}_{channel}", "1" if enabled else "0")
def _notification_overrides(user_uid: str) -> dict:
sync_local_cache("notif_prefs", _notification_prefs_cache)
cached = _notification_prefs_cache.get(user_uid)
if cached is not None:
return cached
overrides: dict = {}
if "notification_preferences" in db.tables:
for row in db["notification_preferences"].find(
user_uid=user_uid, deleted_at=None
):
overrides[row["notification_type"]] = {
channel: bool(
row.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1))
)
for channel, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
_notification_prefs_cache.set(user_uid, overrides)
return overrides
def notification_enabled(user_uid: str, notification_type: str, channel: str) -> bool:
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
return True
override = _notification_overrides(user_uid).get(notification_type)
if override is not None:
return bool(override[channel])
return _notification_default(notification_type, channel)
def get_notification_prefs(user_uid: str) -> list:
overrides = _notification_overrides(user_uid)
result = []
for entry in NOTIFICATION_TYPES:
key = entry["key"]
override = overrides.get(key)
channels = {
channel: bool(override[channel])
if override
else _notification_default(key, channel)
for channel in _NOTIFICATION_CHANNEL_COLUMNS
}
result.append(
{
"key": key,
"label": entry["label"],
"description": entry["description"],
**channels,
"customized": override is not None,
}
)
return result
def set_notification_pref(
user_uid: str, notification_type: str, channel: str, enabled: bool
) -> dict:
if notification_type not in _NOTIFICATION_TYPE_KEYS:
raise ValueError(f"Unknown notification type: {notification_type}")
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
raise ValueError(f"Unknown notification channel: {channel}")
from devplacepy.utils import generate_uid
table = get_table("notification_preferences")
now = datetime.now(timezone.utc).isoformat()
existing = table.find_one(user_uid=user_uid, notification_type=notification_type)
if existing:
values = {
name: bool(
existing.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(name, 1))
)
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
else:
values = {
name: _notification_default(notification_type, name)
for name in _NOTIFICATION_CHANNEL_COLUMNS
}
values[channel] = enabled
columns = {
column: 1 if values[name] else 0
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
if existing:
record = {
"id": existing["id"],
**columns,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.update(record, ["id"])
result = {**existing, **record}
else:
result = {
"uid": generate_uid(),
"user_uid": user_uid,
"notification_type": notification_type,
**columns,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.insert(result)
bump_cache_version("notif_prefs")
return result
def reset_notification_prefs(user_uid: str, deleted_by: str | None = None) -> int:
if "notification_preferences" not in db.tables:
return 0
count = soft_delete(
"notification_preferences", deleted_by or f"user:{user_uid}", user_uid=user_uid
)
bump_cache_version("notif_prefs")
return int(count)
def mark_notifications_read_by_target(user_uid: str, target_url: str) -> int:
if not user_uid or not target_url or "notifications" not in db.tables:
return 0
notifications_table = get_table("notifications")
ids = [
n["id"]
for n in notifications_table.find(user_uid=user_uid, read=False)
if n.get("target_url")
and (
n["target_url"] == target_url
or n["target_url"].startswith(f"{target_url}#")
)
]
if not ids:
return 0
with db:
for notification_id in ids:
notifications_table.update({"id": notification_id, "read": True}, ["id"])
from devplacepy.templating import clear_unread_cache
clear_unread_cache(user_uid)
return len(ids)

View File

@ -0,0 +1,95 @@
# retoor <retoor@molodetz.nl>
from .core import db, get_table
from .relations import get_blocked_uids
PAGE_SIZE = 25
def paginate(
table,
*clauses,
before=None,
order=None,
cursor_field="created_at",
viewer_uid=None,
**filters,
):
order = order or ["-" + cursor_field]
clauses = list(clauses)
if table.has_column("deleted_at") and "deleted_at" not in filters:
clauses.append(table.table.columns.deleted_at.is_(None))
if viewer_uid and table.has_column("user_uid"):
blocked = get_blocked_uids(viewer_uid)
if blocked:
clauses.append(table.table.columns.user_uid.notin_(blocked))
if before:
clauses.append(table.table.columns[cursor_field] < before)
rows = list(table.find(*clauses, **filters, order_by=order, _limit=PAGE_SIZE + 1))
has_more = len(rows) > PAGE_SIZE
rows = rows[:PAGE_SIZE]
next_cursor = rows[-1][cursor_field] if has_more and rows else None
return rows, next_cursor
def interleave_by_author(rows, uid_key="user_uid"):
remaining = list(rows)
spread = []
last_owner = object()
while remaining:
pick = next(
(
index
for index, row in enumerate(remaining)
if row.get(uid_key) != last_owner
),
0,
)
row = remaining.pop(pick)
spread.append(row)
last_owner = row.get(uid_key)
return spread
def paginate_diverse(
table,
*clauses,
before=None,
order=None,
cursor_field="created_at",
uid_key="user_uid",
viewer_uid=None,
**filters,
):
rows, next_cursor = paginate(
table,
*clauses,
before=before,
order=order,
cursor_field=cursor_field,
viewer_uid=viewer_uid,
**filters,
)
return interleave_by_author(rows, uid_key=uid_key), next_cursor
def get_user_post_count(user_uid: str) -> int:
if "posts" not in db.tables:
return 0
return get_table("posts").count(user_uid=user_uid, deleted_at=None)
def build_pagination(page, total, per_page=25):
total_pages = max(1, __import__("math").ceil(total / per_page))
page = max(1, min(page, total_pages))
return {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"has_prev": page > 1,
"has_next": page < total_pages,
"prev_page": page - 1,
"next_page": page + 1,
}

View File

@ -0,0 +1,168 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, _in_clause, _now_iso, db, get_table
from .users import get_users_by_uids
from .soft_delete import soft_delete, soft_delete_in
VOTABLE_TARGETS: dict[str, str] = {
"post": "posts",
"project": "projects",
"gist": "gists",
"comment": "comments",
}
STAR_TARGETS: set[str] = {"post", "project", "gist"}
_authors_cache = TTLCache(ttl=300, max_size=200)
def _ranked_authors() -> list:
cached = _authors_cache.get("ranked")
if cached is not None:
return cached
sources = [
(target_type, table_name)
for target_type, table_name in VOTABLE_TARGETS.items()
if table_name in db.tables
]
if "votes" not in db.tables or not sources:
_authors_cache.set("ranked", [])
return []
target_union = " UNION ALL ".join(
f"SELECT uid, user_uid, '{target_type}' AS target_type FROM {table_name} WHERE deleted_at IS NULL"
for target_type, table_name in sources
)
rows = db.query(
f"SELECT t.user_uid, SUM(v.value) AS total "
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
f"WHERE v.deleted_at IS NULL "
f"GROUP BY t.user_uid HAVING SUM(v.value) > 0 ORDER BY total DESC"
)
ranked = [(row["user_uid"], row["total"]) for row in rows]
users_map = get_users_by_uids([uid for uid, _ in ranked])
authors = []
for uid, total in ranked:
user = users_map.get(uid)
if user:
author = dict(user)
author["stars"] = total
authors.append(author)
_authors_cache.set("ranked", authors)
_authors_cache.set(
"rank_map",
{author["uid"]: position for position, author in enumerate(authors, start=1)},
)
return authors
def _rank_map() -> dict:
cached = _authors_cache.get("rank_map")
if cached is not None:
return cached
_ranked_authors()
return _authors_cache.get("rank_map") or {}
def get_top_authors(limit: int = 5) -> list:
return _ranked_authors()[:limit]
def get_leaderboard(limit: int = 50, offset: int = 0) -> list:
sliced = _ranked_authors()[offset : offset + limit]
leaderboard = []
for position, author in enumerate(sliced, start=offset + 1):
entry = dict(author)
entry["rank"] = position
leaderboard.append(entry)
return leaderboard
def get_user_rank(user_uid: str):
return _rank_map().get(user_uid)
def get_user_stars(user_uid: str) -> int:
if "votes" not in db.tables:
return 0
target_union = " UNION ALL ".join(
f"SELECT uid, '{target_type}' AS target_type FROM {table_name} WHERE user_uid = :u AND deleted_at IS NULL"
for target_type, table_name in VOTABLE_TARGETS.items()
if table_name in db.tables
)
if not target_union:
return 0
for row in db.query(
f"SELECT COALESCE(SUM(v.value), 0) AS s "
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
f"WHERE v.deleted_at IS NULL",
u=user_uid,
):
return row["s"] or 0
return 0
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
table_name = VOTABLE_TARGETS.get(target_type)
if not table_name:
return
if target_type in STAR_TARGETS:
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
_authors_cache.clear()
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
return
stamp = _now_iso()
soft_delete_in(
"reactions", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
)
soft_delete_in(
"bookmarks", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
)
if target_type == "post" and "polls" in db.tables:
for uid in uids:
for poll in db["polls"].find(post_uid=uid, deleted_at=None):
soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid)
def delete_engagement(target_type: str, target_uids: list) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
return
if "reactions" in db.tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
db.query(
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
**params,
)
if "bookmarks" in db.tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
db.query(
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
**params,
)
if target_type == "post" and "polls" in db.tables:
for uid in uids:
for poll in db["polls"].find(post_uid=uid):
if "poll_votes" in db.tables:
db["poll_votes"].delete(poll_uid=poll["uid"])
if "poll_options" in db.tables:
db["poll_options"].delete(poll_uid=poll["uid"])
db["polls"].delete(post_uid=uid)
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
table_name = VOTABLE_TARGETS.get(target_type)
if not table_name:
return None
row = get_table(table_name).find_one(uid=target_uid, deleted_at=None)
return row["user_uid"] if row else None

View File

@ -0,0 +1,45 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, db, sync_local_cache
_relations_cache = TTLCache(ttl=300, max_size=2000)
def get_user_relations(viewer_uid: str | None) -> dict:
if not viewer_uid:
return {"block": frozenset(), "mute": frozenset()}
sync_local_cache("relations", _relations_cache)
cached = _relations_cache.get(viewer_uid)
if cached is not None:
return cached
block: set = set()
mute: set = set()
if "user_relations" in db.tables:
for row in db["user_relations"].find(user_uid=viewer_uid, deleted_at=None):
target = row["target_uid"]
if row["kind"] == "block":
block.add(target)
elif row["kind"] == "mute":
mute.add(target)
result = {"block": frozenset(block), "mute": frozenset(mute)}
_relations_cache.set(viewer_uid, result)
return result
def get_blocked_uids(viewer_uid: str | None) -> frozenset:
return get_user_relations(viewer_uid)["block"]
def get_muted_uids(viewer_uid: str | None) -> frozenset:
return get_user_relations(viewer_uid)["mute"]
def get_silenced_uids(viewer_uid: str | None) -> frozenset:
relations = get_user_relations(viewer_uid)
return relations["block"] | relations["mute"]
def invalidate_user_relations(viewer_uid: str) -> None:
_relations_cache.pop(viewer_uid)
bump_cache_version("relations")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,87 @@
# retoor <retoor@molodetz.nl>
from .core import _in_clause, _now_iso, db, get_table
SEO_META_TYPES = ("post", "project", "gist", "news", "issue")
def get_seo_metadata(target_type: str, target_uid: str) -> dict | None:
if not target_type or not target_uid or "seo_metadata" not in db.tables:
return None
row = get_table("seo_metadata").find_one(
target_type=target_type,
target_uid=str(target_uid),
status="ready",
deleted_at=None,
)
return dict(row) if row else None
def get_seo_metadata_batch(target_type: str, uids: list) -> dict:
uids = [str(uid) for uid in (uids or []) if uid]
if not uids or "seo_metadata" not in db.tables:
return {}
placeholders, params = _in_clause(uids)
params["tt"] = target_type
rows = db.query(
"SELECT * FROM seo_metadata WHERE target_type = :tt AND status = 'ready' "
f"AND deleted_at IS NULL AND target_uid IN ({placeholders})",
**params,
)
return {row["target_uid"]: dict(row) for row in rows}
def has_fresh_seo_metadata(target_type: str, target_uid: str) -> bool:
return get_seo_metadata(target_type, target_uid) is not None
def upsert_seo_metadata(
target_type: str,
target_uid: str,
seo_title: str,
seo_description: str,
seo_keywords: str,
status: str,
source: str,
) -> None:
if not target_type or not target_uid:
return
from devplacepy.utils import generate_uid
table = get_table("seo_metadata")
now = _now_iso()
generated_at = now if status == "ready" else ""
existing = table.find_one(target_type=target_type, target_uid=str(target_uid))
payload = {
"target_type": target_type,
"target_uid": str(target_uid),
"seo_title": seo_title,
"seo_description": seo_description,
"seo_keywords": seo_keywords,
"status": status,
"source": source,
"generated_at": generated_at,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
if existing:
payload["id"] = existing["id"]
table.update(payload, ["id"])
else:
payload["uid"] = generate_uid()
payload["created_at"] = now
table.insert(payload)
def mark_seo_metadata_stale(target_type: str, target_uid: str) -> None:
if not target_type or not target_uid or "seo_metadata" not in db.tables:
return
table = get_table("seo_metadata")
existing = table.find_one(target_type=target_type, target_uid=str(target_uid))
if existing:
table.update(
{"id": existing["id"], "status": "pending", "updated_at": _now_iso()},
["id"],
)

View File

@ -0,0 +1,48 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, db, get_table, sync_local_cache
def internal_gateway_key() -> str:
return get_setting("gateway_internal_key", "")
_settings_cache = TTLCache(ttl=60, max_size=100)
def get_setting(key: str, default: str = "") -> str:
sync_local_cache("settings", _settings_cache)
cached = _settings_cache.get(key)
if cached is not None:
return cached
if "site_settings" not in db.tables:
return default
entry = db["site_settings"].find_one(key=key)
if entry is None:
return default
_settings_cache.set(key, entry["value"])
return entry["value"]
def get_int_setting(key: str, default: int) -> int:
raw = get_setting(key, str(default))
try:
return int(raw)
except (TypeError, ValueError):
return default
def set_setting(key: str, value: str) -> None:
settings = get_table("site_settings")
existing = settings.find_one(key=key)
if existing:
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
else:
settings.insert({"uid": f"setting_{key}", "key": key, "value": value})
_settings_cache.set(key, value)
bump_cache_version("settings")
def clear_settings_cache() -> None:
_settings_cache.clear()
bump_cache_version("settings")

View File

@ -0,0 +1,186 @@
# retoor <retoor@molodetz.nl>
from .core import _drop_index, _in_clause, _index, _now_iso, db
from .pagination import build_pagination
SOFT_DELETE_TABLES = [
"posts",
"comments",
"gists",
"projects",
"news",
"news_images",
"project_files",
"attachments",
"votes",
"reactions",
"bookmarks",
"follows",
"poll_votes",
"polls",
"poll_options",
"sessions",
"instances",
"instance_schedules",
"backup_schedules",
"devii_conversations",
"devii_tasks",
"devii_lessons",
"devii_virtual_tools",
"user_customizations",
"project_forks",
"issue_tickets",
"issue_comment_authors",
"notification_preferences",
"deepsearch_sessions",
"deepsearch_messages",
"devrant_tokens",
"access_tokens",
"email_accounts",
"user_relations",
"seo_metadata",
]
def ensure_soft_delete_columns(table, *, db_handle=None):
handle = db_handle or db
if table not in handle.tables:
return
target = handle[table]
if not target.has_column("deleted_at"):
target.create_column_by_example("deleted_at", "")
if not target.has_column("deleted_by"):
target.create_column_by_example("deleted_by", "")
_drop_index(handle, f"idx_{table}_deleted")
_index(
handle,
table,
f"idx_{table}_trash",
["deleted_at"],
where="deleted_at IS NOT NULL",
)
def soft_delete(table_name, deleted_by, *, stamp=None, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
if not table.has_column("deleted_at"):
return 0
rows = list(table.find(deleted_at=None, **criteria))
if not rows:
return 0
stamp = stamp or _now_iso()
for row in rows:
table.update(
{"id": row["id"], "deleted_at": stamp, "deleted_by": deleted_by}, ["id"]
)
return len(rows)
def soft_delete_in(table_name, column, uids, deleted_by, *, stamp=None, **extra):
uids = [uid for uid in (uids or []) if uid]
if not uids or table_name not in db.tables:
return 0
if not db[table_name].has_column("deleted_at"):
return 0
placeholders, params = _in_clause(uids)
params["dat"] = stamp or _now_iso()
params["dby"] = deleted_by
extra_sql = ""
for index, (key, value) in enumerate(extra.items()):
params[f"x{index}"] = value
extra_sql += f" AND {key} = :x{index}"
db.query(
f"UPDATE {table_name} SET deleted_at = :dat, deleted_by = :dby "
f"WHERE {column} IN ({placeholders}) AND deleted_at IS NULL{extra_sql}",
**params,
)
return len(uids)
def restore(table_name, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
if not table.has_column("deleted_at"):
return 0
rows = [row for row in table.find(**criteria) if row.get("deleted_at")]
for row in rows:
table.update({"id": row["id"], "deleted_at": None, "deleted_by": None}, ["id"])
return len(rows)
def purge(table_name, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
count = table.count(**criteria)
table.delete(**criteria)
return int(count)
def list_deleted(table_name, page=1, per_page=25):
if table_name not in db.tables or not db[table_name].has_column("deleted_at"):
return [], build_pagination(page, 0, per_page)
table = db[table_name]
column = table.table.columns.deleted_at
total = table.count(column.isnot(None))
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = list(
table.find(
column.isnot(None),
order_by=["-deleted_at"],
_limit=pagination["per_page"],
_offset=offset,
)
)
return rows, pagination
def count_deleted(table_name):
if table_name not in db.tables or not db[table_name].has_column("deleted_at"):
return 0
table = db[table_name]
return int(table.count(table.table.columns.deleted_at.isnot(None)))
def restore_event(stamp):
if not stamp:
return 0
restored = 0
for table_name in SOFT_DELETE_TABLES:
if table_name in db.tables and db[table_name].has_column("deleted_at"):
restored += int(
db.query(
f"SELECT COUNT(*) AS n FROM {table_name} WHERE deleted_at = :s",
s=stamp,
).__next__()["n"]
)
db.query(
f"UPDATE {table_name} SET deleted_at = NULL, deleted_by = NULL "
f"WHERE deleted_at = :s",
s=stamp,
)
return restored
def purge_event(stamp):
if not stamp:
return []
purged = []
for table_name in SOFT_DELETE_TABLES:
if table_name in db.tables and db[table_name].has_column("deleted_at"):
rows = list(
db.query(
f"SELECT * FROM {table_name} WHERE deleted_at = :s", s=stamp
)
)
if rows:
purged.append((table_name, rows))
db.query(
f"DELETE FROM {table_name} WHERE deleted_at = :s", s=stamp
)
return purged

View File

@ -0,0 +1,151 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, datetime, db, timedelta, timezone
from .ranking import get_top_authors
_stats_cache = TTLCache(ttl=30, max_size=50)
def get_site_stats() -> dict:
cached = _stats_cache.get("site")
if cached is not None:
return cached
today_start = (
datetime.now(timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
.isoformat()
)
stats = {
"total_members": db["users"].count() if "users" in db.tables else 0,
"posts_today": db["posts"].count(
created_at={">=": today_start}, deleted_at=None
)
if "posts" in db.tables
else 0,
"total_projects": db["projects"].count(deleted_at=None)
if "projects" in db.tables
else 0,
"total_gists": db["gists"].count(deleted_at=None)
if "gists" in db.tables
else 0,
}
_stats_cache.set("site", stats)
return stats
_analytics_cache = TTLCache(ttl=300, max_size=50)
def get_platform_analytics(top_n: int = 10) -> dict:
top_n = max(1, min(int(top_n or 10), 50))
cache_key = f"analytics:{top_n}"
cached = _analytics_cache.get(cache_key)
if cached is not None:
return cached
now = datetime.now(timezone.utc)
d1 = (now - timedelta(days=1)).isoformat()
d7 = (now - timedelta(days=7)).isoformat()
d30 = (now - timedelta(days=30)).isoformat()
active_24h = active_7d = active_30d = 0
sources = [
table
for table in ("posts", "comments", "gists", "projects")
if table in db.tables
]
if sources:
union = " UNION ALL ".join(
f"SELECT user_uid, created_at FROM {table} WHERE deleted_at IS NULL"
for table in sources
)
rows = db.query(
"SELECT "
"COUNT(DISTINCT CASE WHEN created_at >= :d1 THEN user_uid END) AS a1, "
"COUNT(DISTINCT CASE WHEN created_at >= :d7 THEN user_uid END) AS a7, "
"COUNT(DISTINCT CASE WHEN created_at >= :d30 THEN user_uid END) AS a30 "
f"FROM ({union})",
d1=d1,
d7=d7,
d30=d30,
)
for row in rows:
active_24h = row["a1"] or 0
active_7d = row["a7"] or 0
active_30d = row["a30"] or 0
signed_in_now = 0
if "sessions" in db.tables:
for row in db.query(
"SELECT COUNT(DISTINCT user_uid) AS n FROM sessions WHERE expires_at > :now AND deleted_at IS NULL",
now=now.isoformat(),
):
signed_in_now = row["n"] or 0
def _users_created_since(cutoff: str) -> int:
return (
db["users"].count(created_at={">=": cutoff}) if "users" in db.tables else 0
)
site = get_site_stats()
totals = {
"posts": db["posts"].count(deleted_at=None) if "posts" in db.tables else 0,
"comments": db["comments"].count(deleted_at=None)
if "comments" in db.tables
else 0,
"gists": site["total_gists"],
"projects": site["total_projects"],
"news": db["news"].count(deleted_at=None) if "news" in db.tables else 0,
}
top_authors = [
{"username": author.get("username", ""), "stars": author.get("stars", 0)}
for author in get_top_authors(top_n)
]
result = {
"total_members": site["total_members"],
"active_24h": active_24h,
"active_7d": active_7d,
"active_30d": active_30d,
"signed_in_now": signed_in_now,
"new_24h": _users_created_since(d1),
"new_7d": _users_created_since(d7),
"new_30d": _users_created_since(d30),
"posts_today": site["posts_today"],
"totals": totals,
"top_authors": top_authors,
"active_definition": (
"Active = created a post, comment, gist, or project within the window. "
"Automated/bot accounts are not separately flagged."
),
"signed_in_definition": (
"signed_in_now = distinct members holding an unexpired session cookie, i.e. who "
"logged in within the session lifetime (default 7 days, up to 30 with remember-me) "
"and have not logged out. It is NOT a real-time presence/online count - the platform "
"does not track per-request last-seen activity - so it is normally a large fraction "
"of recently active members. Do not describe it as 'currently online' or 'logged in "
"right now'."
),
}
_analytics_cache.set(cache_key, result)
return result
_gist_languages_cache = TTLCache(ttl=60, max_size=200)
def get_gist_languages() -> set[str]:
cached = _gist_languages_cache.get("codes")
if cached is not None:
return cached
codes: set[str] = set()
if "gists" in db.tables:
for row in db.query(
"SELECT DISTINCT language FROM gists WHERE deleted_at IS NULL"
):
language = row.get("language")
if language:
codes.add(language)
_gist_languages_cache.set("codes", codes)
return codes

View File

@ -0,0 +1,128 @@
# retoor <retoor@molodetz.nl>
from .core import datetime, db, get_table, timezone
def _add_usage(usage_table: str, user_uid: str, totals: dict) -> None:
if not user_uid or int(totals.get("calls") or 0) <= 0 or usage_table not in db.tables:
return
with db:
db.query(
f"INSERT INTO {usage_table} "
"(user_uid, calls, prompt_tokens, completion_tokens, total_tokens, cost_usd, "
"upstream_latency_ms, total_latency_ms, updated_at) "
"VALUES (:user_uid, :calls, :prompt, :completion, :total, :cost, :ulat, :tlat, :now) "
"ON CONFLICT(user_uid) DO UPDATE SET "
"calls = calls + excluded.calls, "
"prompt_tokens = prompt_tokens + excluded.prompt_tokens, "
"completion_tokens = completion_tokens + excluded.completion_tokens, "
"total_tokens = total_tokens + excluded.total_tokens, "
"cost_usd = cost_usd + excluded.cost_usd, "
"upstream_latency_ms = upstream_latency_ms + excluded.upstream_latency_ms, "
"total_latency_ms = total_latency_ms + excluded.total_latency_ms, "
"updated_at = excluded.updated_at",
user_uid=user_uid,
calls=int(totals.get("calls") or 0),
prompt=int(totals.get("prompt_tokens") or 0),
completion=int(totals.get("completion_tokens") or 0),
total=int(totals.get("total_tokens") or 0),
cost=float(totals.get("cost_usd") or 0.0),
ulat=float(totals.get("upstream_latency_ms") or 0.0),
tlat=float(totals.get("total_latency_ms") or 0.0),
now=datetime.now(timezone.utc).isoformat(),
)
def _get_usage(usage_table: str, user_uid: str) -> dict:
empty = {
"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,
"avg_tokens": 0.0,
"avg_upstream_latency_ms": 0.0,
"avg_total_latency_ms": 0.0,
"avg_tokens_per_second": 0.0,
"avg_cost_usd": 0.0,
"updated_at": None,
}
if not user_uid or usage_table not in db.tables:
return empty
row = get_table(usage_table).find_one(user_uid=user_uid)
if not row:
return empty
calls = int(row.get("calls") or 0)
completion = int(row.get("completion_tokens") or 0)
total_tokens = int(row.get("total_tokens") or 0)
cost = float(row.get("cost_usd") or 0.0)
upstream_ms = float(row.get("upstream_latency_ms") or 0.0)
total_ms = float(row.get("total_latency_ms") or 0.0)
return {
"calls": calls,
"prompt_tokens": int(row.get("prompt_tokens") or 0),
"completion_tokens": completion,
"total_tokens": total_tokens,
"cost_usd": cost,
"upstream_latency_ms": upstream_ms,
"total_latency_ms": total_ms,
"avg_tokens": round(total_tokens / calls, 1) if calls else 0.0,
"avg_upstream_latency_ms": round(upstream_ms / calls, 1) if calls else 0.0,
"avg_total_latency_ms": round(total_ms / calls, 1) if calls else 0.0,
"avg_tokens_per_second": round(completion / (upstream_ms / 1000.0), 1)
if upstream_ms > 0
else 0.0,
"avg_cost_usd": (cost / calls) if calls else 0.0,
"updated_at": row.get("updated_at"),
}
def add_correction_usage(user_uid: str, totals: dict) -> None:
_add_usage("correction_usage", user_uid, totals)
def get_correction_usage(user_uid: str) -> dict:
return _get_usage("correction_usage", user_uid)
def add_modifier_usage(user_uid: str, totals: dict) -> None:
_add_usage("modifier_usage", user_uid, totals)
def get_modifier_usage(user_uid: str) -> dict:
return _get_usage("modifier_usage", user_uid)
NEWS_USAGE_KEY = "news"
def add_news_usage(totals: dict) -> None:
_add_usage("news_usage", NEWS_USAGE_KEY, totals)
def get_news_usage() -> dict:
return _get_usage("news_usage", NEWS_USAGE_KEY)
ISSUE_USAGE_KEY = "issues"
def add_issue_usage(totals: dict) -> None:
_add_usage("issue_usage", ISSUE_USAGE_KEY, totals)
def get_issue_usage() -> dict:
return _get_usage("issue_usage", ISSUE_USAGE_KEY)
SEO_USAGE_KEY = "seo_meta"
def add_seo_usage(totals: dict) -> None:
_add_usage("seo_usage", SEO_USAGE_KEY, totals)
def get_seo_usage() -> dict:
return _get_usage("seo_usage", SEO_USAGE_KEY)

View File

@ -0,0 +1,84 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, db, sync_local_cache
def get_users_by_uids(uids):
if not uids or "users" not in db.tables:
return {}
users = db["users"]
if "uid" not in users.columns:
return {}
seen = set()
unique = [u for u in uids if u not in seen and not seen.add(u)]
return {u["uid"]: u for u in users.find(users.table.columns.uid.in_(unique))}
_admins_cache = TTLCache(ttl=300, max_size=4)
def invalidate_admins_cache() -> None:
_admins_cache.clear()
bump_cache_version("admins")
def get_admin_uids():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("uids")
if cached is not None:
return list(cached)
if "users" not in db.tables:
return []
rows = db.query("SELECT uid FROM users WHERE role = 'Admin'")
uids = [row["uid"] for row in rows]
_admins_cache.set("uids", uids)
return list(uids)
def set_user_timezone(user_uid: str, tz_name: str) -> None:
if "users" not in db.tables or not user_uid or not tz_name:
return
users = db["users"]
if not users.has_column("timezone"):
users.create_column_by_example("timezone", "")
current = users.find_one(uid=user_uid)
if current and current.get("timezone") == tz_name:
return
users.update({"uid": user_uid, "timezone": tz_name}, ["uid"])
def get_primary_admin_uid():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("primary")
if cached is not None:
return cached or None
if "users" not in db.tables:
return None
rows = list(
db.query(
"SELECT uid FROM users WHERE role = 'Admin' "
"ORDER BY created_at ASC, id ASC LIMIT 1"
)
)
primary = rows[0]["uid"] if rows else None
_admins_cache.set("primary", primary or "")
return primary
def search_users_by_username(q, *, exclude_uid=None, limit=10):
if not q or "users" not in db.tables:
return []
if exclude_uid is not None:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q AND uid != :me LIMIT :limit",
q=f"%{q}%",
me=exclude_uid,
limit=limit,
)
else:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q LIMIT :limit",
q=f"%{q}%",
limit=limit,
)
return [{"uid": r["uid"], "username": r["username"]} for r in rows]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
# retoor <retoor@molodetz.nl>
from ._shared import (
BOOKMARK_TARGETS,
COMMENT_TARGETS,
GIST_LANGUAGES,
NON_BODY_ENDPOINTS,
PROJECT_TYPES,
REACTION_TARGETS,
ROLE_LABELS,
SERVICE_ACTIONS,
VOTE_TARGETS,
endpoint,
field,
)
from .groups import ORDERED_GROUPS as API_GROUPS
from .negotiation import _apply_negotiated_responses
from .services_group import build_services_group
from .render import api_doc_pages, get_group, render_group, _substitute
_apply_negotiated_responses(API_GROUPS)
__all__ = [
"API_GROUPS",
"build_services_group",
"get_group",
"api_doc_pages",
"render_group",
"_substitute",
"endpoint",
"field",
"ROLE_LABELS",
"NON_BODY_ENDPOINTS",
"VOTE_TARGETS",
"REACTION_TARGETS",
"BOOKMARK_TARGETS",
"COMMENT_TARGETS",
"PROJECT_TYPES",
"GIST_LANGUAGES",
"SERVICE_ACTIONS",
]

View File

@ -0,0 +1,98 @@
# retoor <retoor@molodetz.nl>
VOTE_TARGETS = ["post", "comment", "gist", "project"]
REACTION_TARGETS = ["post", "comment", "gist", "project"]
BOOKMARK_TARGETS = ["post", "gist", "project", "news"]
COMMENT_TARGETS = ["post", "project", "news", "issue", "gist"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
GIST_LANGUAGES = [
"python",
"javascript",
"typescript",
"html",
"css",
"c",
"cpp",
"java",
"go",
"rust",
"sql",
"bash",
"json",
"markdown",
"plaintext",
]
SERVICE_ACTIONS = [
("start", "Start the service"),
("stop", "Stop the service"),
("run", "Trigger a single run now"),
("clear-logs", "Clear the in-memory log buffer"),
]
def field(
name,
location,
type="string",
required=False,
example="",
description="",
options=None,
):
spec = {
"name": name,
"location": location,
"type": type,
"required": required,
"example": example,
"description": description,
}
if options:
spec["options"] = list(options)
return spec
ROLE_LABELS = {"public": "Public", "user": "Member", "admin": "Admin"}
def endpoint(
id,
method,
path,
title,
summary,
auth="user",
ajax=False,
encoding="none",
interactive=True,
destructive=False,
params=None,
notes=None,
sample_response=None,
negotiation=None,
):
return {
"id": id,
"method": method,
"path": path,
"title": title,
"summary": summary,
"auth": auth,
"min_role": ROLE_LABELS.get(auth, auth.title()),
"ajax": ajax,
"encoding": encoding,
"interactive": interactive,
"destructive": destructive,
"params": params or [],
"notes": notes or [],
"sample_response": sample_response,
"negotiation": negotiation,
}
NON_BODY_ENDPOINTS = {
"avatar",
"gateway-passthrough",
"notifications-open",
"admin-bots-frame",
}

View File

@ -0,0 +1,43 @@
# retoor <retoor@molodetz.nl>
from . import (
conventions,
auth,
lookups,
social_actions,
content,
profiles,
messaging,
notifications,
uploads,
project_files,
containers,
tools,
push,
issues,
gateway,
services,
admin,
game,
)
ORDERED_GROUPS = [
conventions.GROUP,
auth.GROUP,
lookups.GROUP,
social_actions.GROUP,
content.GROUP,
profiles.GROUP,
messaging.GROUP,
notifications.GROUP,
uploads.GROUP,
project_files.GROUP,
containers.GROUP,
tools.GROUP,
push.GROUP,
issues.GROUP,
gateway.GROUP,
services.GROUP,
admin.GROUP,
game.GROUP,
]

View File

@ -0,0 +1,740 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "admin",
"title": "Admin API",
"admin": True,
"intro": """
# Admin API
Site administration endpoints for users, news curation, and settings. Every call requires an
**admin** account. Background service management lives on the
[Background Services](/docs/services.html) page.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="admin-users",
method="GET",
path="/admin/users",
title="List users",
summary="Paginated user management page. Returns HTML.",
auth="admin",
interactive=True,
params=[field("page", "query", "int", False, "1", "Page number.")],
),
endpoint(
id="admin-media",
method="GET",
path="/admin/media",
title="Media trash",
summary=(
"Moderation view of deleted media. When a member or admin deletes an "
"attachment it is soft-deleted: hidden from the gallery and from its parent "
"object, but the row and file are kept and the relation to the parent is "
"preserved. This page lists every soft-deleted attachment, newest first, with "
"its uploader, so an admin can restore or permanently purge it. Returns HTML."
),
auth="admin",
interactive=True,
params=[field("page", "query", "int", False, "1", "Page number.")],
),
endpoint(
id="media-restore",
method="POST",
path="/media/{uid}/restore",
title="Restore media",
summary=(
"Restore a soft-deleted attachment. Because the parent relation is never "
"cleared, it reappears in the owner's Media tab and on its original post, "
"project, or gist immediately."
),
auth="admin",
destructive=True,
params=[
field(
"uid", "path", "string", True, "", "Soft-deleted attachment uid."
),
],
sample_response={"ok": True, "redirect": "/admin/media"},
),
endpoint(
id="admin-media-purge",
method="POST",
path="/admin/media/{uid}/purge",
title="Purge media",
summary=(
"Permanently delete a soft-deleted attachment: removes the database row and "
"deletes the file from disk. This cannot be undone and is the only way media "
"is hard-deleted from the UI."
),
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "", "Attachment uid to purge."),
],
sample_response={"ok": True, "redirect": "/admin/media"},
),
endpoint(
id="admin-audit-log",
method="GET",
path="/admin/audit-log",
title="Audit log",
summary=(
"Paginated, filterable audit event list (newest first, 25 per page). "
"Negotiates HTML or JSON. Every state-changing action on the platform "
"is recorded here with actor, origin, target, change, and result."
),
auth="admin",
interactive=True,
params=[
field("page", "query", "int", False, "1", "Page number."),
field("event_key", "query", "string", False, "post.create", "Filter by exact event key."),
field("category", "query", "string", False, "admin", "Filter by category (auth, content, admin, container, ...)."),
field("actor_role", "query", "string", False, "admin", "Filter by actor role at action time."),
field("actor_uid", "query", "string", False, "", "Filter by acting user uid."),
field("origin", "query", "string", False, "web", "Filter by origin (web, api, devii, cli, service, scheduler)."),
field("result", "query", "string", False, "denied", "Filter by result (success, failure, denied)."),
field("q", "query", "string", False, "", "Free-text search over summary, event key, and target."),
field("date_from", "query", "string", False, "", "ISO date lower bound (inclusive)."),
field("date_to", "query", "string", False, "", "ISO date upper bound (inclusive)."),
],
sample_response={
"entries": [
{
"uid": "AUDIT_UID",
"created_at": "2026-06-11T20:00:00+00:00",
"event_key": "admin.setting.update",
"category": "admin",
"actor_username": "ADMIN",
"actor_role": "admin",
"origin": "web",
"via_agent": 0,
"target_type": "setting",
"old_value": "0",
"new_value": "1",
"result": "success",
}
],
"pagination": {"page": 1, "total": 1, "total_pages": 1},
"filters": {},
"options": {"category": ["admin", "auth", "content"]},
},
),
endpoint(
id="admin-audit-event",
method="GET",
path="/admin/audit-log/{uid}",
title="Audit event",
summary="A single audit event with its full row and every related-object link.",
auth="admin",
interactive=True,
params=[field("uid", "path", "string", True, "AUDIT_UID", "Audit event uid.")],
sample_response={
"event": {"uid": "AUDIT_UID", "event_key": "container.instance.start", "result": "success"},
"links": [
{"relation": "actor", "object_type": "user", "object_uid": "USER_UID"},
{"relation": "instance", "object_type": "instance", "object_uid": "INSTANCE_UID"},
],
},
),
endpoint(
id="admin-analytics",
method="GET",
path="/admin/analytics",
title="Site analytics",
summary=(
"One-call aggregate analytics, returned as JSON: total members, active users "
"in the last 24h/7d/30d, signed_in_now, new signups (24h/7d/30d), content "
"totals (posts, comments, gists, projects, news), and top authors. Use this "
"instead of paging the user list to count or measure activity."
),
auth="admin",
interactive=True,
params=[
field(
"top_n",
"query",
"int",
False,
"10",
"How many top authors to include (1-50).",
)
],
notes=[
"`signed_in_now` counts members holding an unexpired session (logged in within the session lifetime, default 7-30 days), not a real-time online/presence count; the response carries a `signed_in_definition` explaining this. `active_*` means created content in the window (`active_definition`).",
"This is the endpoint the Devii assistant calls as `site_analytics`; see [Devii internals](/docs/devii-internals.html).",
],
),
endpoint(
id="admin-ai-usage",
method="GET",
path="/admin/ai-usage/data",
title="AI gateway usage analytics",
summary=(
"One-call AI gateway metrics, returned as JSON for a bounded time window: request "
"volume and throughput, token usage with averages and percentiles (p50/p90/p95/p99), "
"latency (upstream, gateway overhead, queue wait, connection establishment), error "
"rates by category, cost in USD (per model, per caller, input vs output, projected "
"monthly burn, caching savings), caller behavior, and an hourly breakdown. Cost is "
"taken from the upstream native cost when present (OpenRouter) and computed from the "
"configured per-million pricing otherwise (DeepSeek)."
),
auth="admin",
interactive=True,
params=[
field(
"hours",
"query",
"int",
False,
"48",
"Lookback window in hours (1-168).",
),
field(
"top_n",
"query",
"int",
False,
"10",
"How many rows in each top-N breakdown.",
),
],
notes=[
"TTFT and inter-token latency are not reported: the gateway forwards non-streaming to the upstream."
],
),
endpoint(
id="admin-user-ai-usage",
method="GET",
path="/admin/users/{uid}/ai-usage",
title="Per-user AI usage",
summary=(
"One user's AI gateway usage over the last 24 hours, returned as JSON: request "
"volume, success and error rates, token totals, cost (window, per hour, per request, "
"and a 30-day projection from the full 24h spend), average latency and throughput, a "
"per-model breakdown, and an hourly cost series. Because Devii operates a signed-in "
"user's account with that user's own API key, this is the user's complete gateway "
"spend, whether driven through Devii or direct API calls. Shown admin-only on the "
"user's profile page."
),
auth="admin",
interactive=True,
params=[
field(
"uid", "path", "string", True, "USER_UID", "Target user UID."
),
field(
"hours",
"query",
"int",
False,
"24",
"Lookback window in hours (1-168).",
),
],
sample_response={
"owner_id": "USER_UID",
"window_hours": 24,
"requests": 42,
"success": 41,
"failed": 1,
"success_pct": 97.6,
"error_pct": 2.4,
"tokens": {"prompt": 120000, "completion": 38000, "total": 158000},
"cost": {
"window_usd": 0.214,
"per_hour_usd": 0.0089,
"per_request_usd": 0.0051,
"projected_30d_usd": 6.42,
},
"latency": {"avg_ms": 1830.0, "avg_tps": 41.2},
"by_model": [
{
"key": "molodetz",
"requests": 42,
"total_tokens": 158000,
"cost_usd": 0.214,
}
],
"hourly": [
{
"hour": "2026-06-08T16",
"requests": 6,
"cost_usd": 0.031,
"total_tokens": 22000,
}
],
},
),
endpoint(
id="admin-user-role",
method="POST",
path="/admin/users/{uid}/role",
title="Set a user role",
summary="Promote or demote a user. You cannot change your own role.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"uid", "path", "string", True, "USER_UID", "Target user UID."
),
field(
"role",
"form",
"enum",
True,
"member",
"New role.",
["member", "admin"],
),
],
),
endpoint(
id="admin-user-password",
method="POST",
path="/admin/users/{uid}/password",
title="Reset a user password",
summary="Set a new password for a user.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"uid", "path", "string", True, "USER_UID", "Target user UID."
),
field(
"password",
"form",
"string",
True,
"newpassword",
"New password, 6+ characters.",
),
],
),
endpoint(
id="admin-user-toggle",
method="POST",
path="/admin/users/{uid}/toggle",
title="Enable or disable a user",
summary="Toggle a user's active state. You cannot disable yourself.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "USER_UID", "Target user UID.")
],
),
endpoint(
id="admin-news-list",
method="GET",
path="/admin/news",
title="List news articles",
summary="Paginated news management page. Returns HTML.",
auth="admin",
interactive=True,
params=[field("page", "query", "int", False, "1", "Page number.")],
),
endpoint(
id="admin-news-toggle",
method="POST",
path="/admin/news/{uid}/toggle",
title="Toggle featured",
summary="Toggle an article's featured flag and lock it from the news service's auto-rotation.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
],
),
endpoint(
id="admin-news-publish",
method="POST",
path="/admin/news/{uid}/publish",
title="Toggle published",
summary="Switch an article between draft and published.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
],
),
endpoint(
id="admin-news-landing",
method="POST",
path="/admin/news/{uid}/landing",
title="Toggle landing",
summary="Toggle whether an article shows on the landing page and lock it from the news service's auto-rotation.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
],
),
endpoint(
id="admin-news-delete",
method="POST",
path="/admin/news/{uid}/delete",
title="Delete a news article",
summary="Delete an article and its images.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
],
),
endpoint(
id="admin-settings-get",
method="GET",
path="/admin/settings",
title="Read site settings",
summary="Return the current site and operational settings. Negotiates HTML or JSON.",
auth="admin",
interactive=True,
),
endpoint(
id="admin-settings",
method="POST",
path="/admin/settings",
title="Save site settings",
summary="Update operational and site settings. Empty fields are skipped.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"site_name", "form", "string", False, "DevPlace", "Site name."
),
field(
"rate_limit_per_minute",
"form",
"string",
False,
"60",
"Requests per window.",
),
field(
"registration_open",
"form",
"enum",
False,
"1",
"Allow signups.",
["1", "0"],
),
field(
"maintenance_mode",
"form",
"enum",
False,
"0",
"Maintenance gate.",
["1", "0"],
),
],
notes=[
"Accepts every field on the admin settings form; only non-empty values are written."
],
),
endpoint(
id="admin-notification-default",
method="POST",
path="/admin/notifications",
title="Set a notification default",
summary="Set the platform-wide default for one notification type on one channel. Applies to users who have not customized that notification; explicit user choices are unaffected.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"notification_type",
"form",
"string",
True,
"vote",
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
),
field(
"channel",
"form",
"string",
True,
"push",
"One of in_app, push or telegram (telegram is off by default and requires a paired Telegram account).",
),
field(
"value",
"form",
"boolean",
False,
"1",
"1 to enable this notification by default, 0 to disable.",
),
],
sample_response={
"ok": True,
"redirect": "/admin/notifications",
"data": {
"notification_type": "vote",
"channel": "push",
"value": False,
},
},
),
endpoint(
id="admin-user-reset-ai-quota",
method="POST",
path="/admin/users/{uid}/reset-ai-quota",
title="Reset user AI quota",
summary="Delete a specific user's AI gateway ledger rows, resetting their quota.",
auth="admin",
destructive=True,
params=[
field(
"uid", "path", "string", True, "USER_UID", "Target user UID."
),
],
),
endpoint(
id="admin-ai-quota-reset-guests",
method="POST",
path="/admin/ai-quota/reset-guests",
title="Reset guest AI quotas",
summary="Reset AI quota for all anonymous guest users.",
auth="admin",
destructive=True,
),
endpoint(
id="admin-ai-quota-reset-all",
method="POST",
path="/admin/ai-quota/reset-all",
title="Reset all AI quotas",
summary="Reset AI quota for every user (members and guests).",
auth="admin",
destructive=True,
),
endpoint(
id="admin-bots-monitor",
method="GET",
path="/admin/bots",
title="Bot monitor page",
summary="Live low-quality screenshot grid of every running bot persona, one frame per bot.",
auth="admin",
interactive=True,
),
endpoint(
id="admin-bots-data",
method="GET",
path="/admin/bots/data",
title="Bot monitor data",
summary="JSON of every running bot slot with its label, persona, current action/status, and latest frame URL for polling.",
auth="admin",
sample_response={
"enabled": True,
"service_status": "running",
"frames": [
{
"slot": 0,
"username": "bytewren",
"persona": "grumpy_senior",
"action": "POST: rant [3]",
"status": "page load",
"url": "https://example.com/feed",
"label": "bytewren",
"captured_at": 1718366400,
"age_seconds": 2,
"has_image": True,
"active": True,
"frame_url": "/admin/bots/0/frame.jpg?t=1718366400",
}
],
},
),
endpoint(
id="admin-bots-frame",
method="GET",
path="/admin/bots/{slot}/frame.jpg",
title="Bot frame image",
summary="The latest low-quality JPEG screenshot for one bot slot (no-store).",
auth="admin",
encoding="none",
interactive=False,
params=[
field(
"slot",
"path",
"integer",
True,
"0",
"Bot fleet slot index.",
)
],
),
endpoint(
id="admin-backups",
method="GET",
path="/admin/backups",
title="Backups dashboard",
summary=(
"Storage usage, every backup archive, and every backup schedule. Returns HTML "
"(or JSON with Accept: application/json)."
),
auth="admin",
interactive=True,
),
endpoint(
id="admin-backups-data",
method="GET",
path="/admin/backups/data",
title="Backups data",
summary=(
"JSON dashboard payload: per-path storage usage, total data and backup size, "
"disk usage, the backup list, and the schedule list."
),
auth="admin",
),
endpoint(
id="admin-backups-run",
method="POST",
path="/admin/backups/run",
title="Create backup",
summary=(
"Enqueue an async backup job for a target (database, uploads, keys, full). "
"Returns the job uid and a status_url."
),
auth="admin",
params=[
field(
"target",
"form",
"string",
True,
"full",
"One of database, uploads, keys, full.",
)
],
sample_response={
"ok": True,
"uid": "JOB_UID",
"backup_uid": "BACKUP_UID",
"status_url": "/admin/backups/jobs/JOB_UID",
},
),
endpoint(
id="admin-backups-job",
method="GET",
path="/admin/backups/jobs/{uid}",
title="Backup job status",
summary="Status of one backup job (pending, running, done, failed) with archive stats.",
auth="admin",
params=[field("uid", "path", "string", True, "", "Backup job uid.")],
sample_response={
"uid": "JOB_UID",
"kind": "backup",
"status": "done",
"target": "full",
"backup_uid": "BACKUP_UID",
"download_url": "/admin/backups/BACKUP_UID/download",
"bytes_out": 10485760,
"file_count": 1240,
"sha256": "…",
},
),
endpoint(
id="admin-backups-download",
method="GET",
path="/admin/backups/{uid}/download",
title="Download backup",
summary="Stream a completed backup archive as a tar.gz file. Restricted to the primary administrator (the first user created with the Admin role); every other administrator receives 403 Forbidden.",
auth="admin",
encoding="none",
params=[field("uid", "path", "string", True, "", "Backup uid.")],
),
endpoint(
id="admin-backups-delete",
method="POST",
path="/admin/backups/{uid}/delete",
title="Delete backup",
summary="Permanently delete a backup archive and reclaim its disk space.",
auth="admin",
destructive=True,
params=[field("uid", "path", "string", True, "", "Backup uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-create",
method="POST",
path="/admin/backups/schedules/create",
title="Create backup schedule",
summary=(
"Create a recurring backup. kind=interval uses every_seconds; kind=cron uses a "
"5-field cron expression. keep_last rotates older backups of the schedule."
),
auth="admin",
params=[
field("name", "form", "string", True, "Nightly full", "Schedule name."),
field("target", "form", "string", True, "full", "Backup target."),
field("kind", "form", "string", True, "interval", "interval or cron."),
field("every_seconds", "form", "int", False, "86400", "Seconds between runs (kind=interval)."),
field("cron", "form", "string", False, "0 3 * * *", "Cron expression (kind=cron)."),
field("keep_last", "form", "int", False, "7", "Keep only the newest N (0 = all)."),
],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-edit",
method="POST",
path="/admin/backups/schedules/{uid}/edit",
title="Edit backup schedule",
summary="Update a backup schedule's target, trigger, and retention.",
auth="admin",
params=[
field("uid", "path", "string", True, "", "Schedule uid."),
field("name", "form", "string", True, "Nightly full", "Schedule name."),
field("target", "form", "string", True, "full", "Backup target."),
field("kind", "form", "string", True, "interval", "interval or cron."),
field("every_seconds", "form", "int", False, "86400", "Seconds between runs (kind=interval)."),
field("cron", "form", "string", False, "0 3 * * *", "Cron expression (kind=cron)."),
field("keep_last", "form", "int", False, "7", "Keep only the newest N (0 = all)."),
],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-toggle",
method="POST",
path="/admin/backups/schedules/{uid}/toggle",
title="Toggle backup schedule",
summary="Enable or disable a backup schedule.",
auth="admin",
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-run",
method="POST",
path="/admin/backups/schedules/{uid}/run",
title="Run backup schedule now",
summary="Immediately enqueue a backup for a schedule without waiting for its next run.",
auth="admin",
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-delete",
method="POST",
path="/admin/backups/schedules/{uid}/delete",
title="Delete backup schedule",
summary="Delete a backup schedule. Existing archives are kept.",
auth="admin",
destructive=True,
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
],
}

View File

@ -0,0 +1,137 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "auth",
"title": "Authentication",
"intro": """
# Authentication
Create an account, sign in, recover your password, and log out. These are the only endpoints
that set or clear the `session` cookie; every other request authenticates with the methods
described in [Authentication](/docs/authentication.html). The shared rules (content
negotiation, pagination, status codes) live in [Conventions and Errors](/docs/conventions.html).
## Page vs. action
The GET endpoints render HTML sign-up, login, and password-reset forms; they also return the
page data as JSON when requested with `Accept: application/json` (including `page` to
distinguish the form type).
The POST endpoints are **actions**: they accept form fields, set or clear the `session` cookie,
and return a `302` redirect (or the JSON envelope for JSON callers).
**Sign-up requires a valid `g-recaptcha-response`** when reCAPTCHA is enabled. Use the JSON
envelope to see validation errors as `{ "error": "validation", "fields": {...} }`.
""",
"endpoints": [
endpoint(
id="auth-signup",
method="GET",
path="/auth/signup",
title="Sign up page",
summary="Render the registration form. Returns an HTML page.",
auth="public",
interactive=True,
),
endpoint(
id="auth-signup-post",
method="POST",
path="/auth/signup",
title="Sign up",
summary="Create a new account. Sets the session cookie on success.",
auth="public",
encoding="form",
destructive=False,
params=[
field("username", "form", "string", True, "alice", "Username, 3-20 characters."),
field("password", "form", "string", True, "mysecret", "Password, 6+ characters."),
field("confirm_password", "form", "string", True, "mysecret", "Must match password."),
field("g-recaptcha-response", "form", "string", False, "", "reCAPTCHA token when enabled."),
],
),
endpoint(
id="auth-login",
method="GET",
path="/auth/login",
title="Log in page",
summary="Render the login form. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field("next", "query", "string", False, "", "Redirect target after login."),
],
),
endpoint(
id="auth-login-post",
method="POST",
path="/auth/login",
title="Log in",
summary="Authenticate with username and password. Sets the session cookie.",
auth="public",
encoding="form",
params=[
field("username", "form", "string", True, "alice", "Your username."),
field("password", "form", "string", True, "mysecret", "Your password."),
field("next", "form", "string", False, "", "Redirect target after login."),
],
),
endpoint(
id="auth-forgot-password",
method="GET",
path="/auth/forgot-password",
title="Forgot password page",
summary="Render the forgot-password form. Returns an HTML page.",
auth="public",
interactive=True,
),
endpoint(
id="auth-forgot-password-post",
method="POST",
path="/auth/forgot-password",
title="Request password reset",
summary="Send a password-reset email with a one-time link.",
auth="public",
encoding="form",
params=[
field("email", "form", "string", True, "alice@example.com", "Your registered email."),
],
),
endpoint(
id="auth-reset-password",
method="GET",
path="/auth/reset-password/{token}",
title="Reset password page",
summary="Render the password-reset form (only valid with a one-time token). Returns an HTML page.",
auth="public",
interactive=True,
params=[
field("token", "path", "string", True, "RESET_TOKEN", "The one-time reset token from the email."),
],
),
endpoint(
id="auth-reset-password-post",
method="POST",
path="/auth/reset-password/{token}",
title="Reset password",
summary="Set a new password using a one-time reset token.",
auth="public",
encoding="form",
params=[
field("token", "path", "string", True, "RESET_TOKEN", "The one-time reset token from the email."),
field("password", "form", "string", True, "newpass", "New password, 6+ characters."),
field("confirm_password", "form", "string", True, "newpass", "Must match password."),
],
),
endpoint(
id="auth-logout",
method="GET",
path="/auth/logout",
title="Log out",
summary="Clear the session cookie and redirect to the landing page.",
auth="public",
interactive=False,
),
],
}

View File

@ -0,0 +1,579 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "containers",
"title": "Container Manager",
"admin": True,
"intro": """
# Container Manager
Build versioned Docker images for a project and run supervised container instances. Every endpoint is
**administrator only** (running arbitrary Dockerfiles with docker socket access is root-equivalent).
Mutations flip desired state; a single reconciler converges containers to it.
""",
"endpoints": [
endpoint(
id="containers-page",
method="GET",
path="/projects/{project_slug}/containers",
title="Container manager page",
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator who is not the owner of an administrator-hidden project.",
auth="admin",
interactive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
)
],
),
endpoint(
id="containers-admin-index",
method="GET",
path="/admin/containers",
title="Admin containers list",
summary="The admin Containers section: every instance across all projects, each linking to its detail page. Instances attached to another administrator's hidden project are excluded, and per-instance actions return 404 for a non-owner administrator.",
auth="admin",
interactive=True,
),
endpoint(
id="containers-admin-data",
method="GET",
path="/admin/containers/data",
title="Admin containers list data",
summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
auth="admin",
sample_response={
"instances": [
{
"uid": "INSTANCE_UID",
"name": "staging",
"status": "running",
"project_slug": "PROJECT_SLUG",
"project_title": "My Project",
"ingress_slug": "my-service",
"restart_policy": "always",
}
]
},
),
endpoint(
id="containers-admin-instance",
method="GET",
path="/admin/containers/{uid}",
title="Instance detail page",
summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
auth="admin",
interactive=True,
params=[
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
)
],
),
endpoint(
id="containers-admin-edit-page",
method="GET",
path="/admin/containers/{uid}/edit",
title="Edit instance page",
summary="The edit page for one instance (run-as user, boot language/script/command, restart policy, start-on-boot, limits).",
auth="admin",
interactive=True,
params=[
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
)
],
),
endpoint(
id="containers-create-instance",
method="POST",
path="/projects/{project_slug}/containers/instances",
title="Create an instance",
summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field("name", "form", "string", True, "staging", "Instance name."),
field(
"boot_command",
"form",
"string",
False,
"python app.py",
"Optional boot command.",
),
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field(
"env",
"form",
"textarea",
False,
"KEY=VALUE",
"Env vars, one KEY=VALUE per line.",
),
field(
"ports",
"form",
"string",
False,
"80",
"Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one.",
),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field(
"mem_limit", "form", "string", False, "512m", "Memory limit."
),
field(
"restart_policy",
"form",
"enum",
False,
"never",
"Restart policy.",
["never", "always", "on-failure", "unless-stopped"],
),
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
field(
"ingress_slug",
"form",
"string",
False,
"my-service",
"Publish at /p/<slug> (optional).",
),
field(
"ingress_port",
"form",
"integer",
False,
"8899",
"Container port to publish (must be a mapped port).",
),
],
),
endpoint(
id="containers-ingress",
method="GET",
path="/p/{slug}",
title="Container ingress proxy",
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
auth="public",
interactive=True,
params=[
field(
"slug",
"path",
"string",
True,
"my-service",
"The instance's ingress_slug.",
)
],
),
endpoint(
id="containers-instance-action",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
title="Instance lifecycle",
summary="start, stop, restart, pause, or resume an instance (flips desired state).",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"action",
"path",
"enum",
True,
"start",
"Lifecycle action.",
["start", "stop", "restart", "pause", "resume"],
),
],
),
endpoint(
id="containers-instance-logs",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}/logs",
title="Instance logs",
summary="Recent docker logs of a running instance.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field("tail", "query", "integer", False, "200", "Number of lines."),
],
sample_response={"logs": "..."},
),
endpoint(
id="containers-instance-sync",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/sync",
title="Sync workspace",
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"exported": 3, "imported": 1},
),
endpoint(
id="containers-instance-delete",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/delete",
title="Delete instance",
summary="Remove a container instance and mark its container for removal.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
),
endpoint(
id="containers-instance-exec",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/exec",
title="Exec a command",
summary="Run a one-shot command inside a running instance and return its output.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"command",
"form",
"string",
True,
"ls -la /app",
"Shell command to run (via /bin/sh -c).",
),
],
),
endpoint(
id="containers-instance-data",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}",
title="Instance detail data",
summary="Return the full instance row plus runtime info as JSON.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"uid": "INSTANCE_UID", "name": "staging", "status": "running"},
),
endpoint(
id="containers-instance-metrics",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}/metrics",
title="Instance metrics",
summary="Return recent metrics ring-buffer and aggregated stats for a running instance.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"metrics": [], "stats": {}},
),
endpoint(
id="containers-instance-schedules",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/schedules",
title="Create a schedule",
summary="Attach a cron, one-time, interval, or delay schedule to an instance.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"action",
"form",
"string",
True,
"start",
"Lifecycle action to run on schedule (start, stop, restart).",
),
field(
"kind",
"form",
"string",
True,
"cron",
"Schedule kind: cron, once, interval, or delay.",
),
field(
"cron",
"form",
"string",
False,
"0 * * * *",
"Cron expression (when kind is cron).",
),
field(
"run_at",
"form",
"string",
False,
"2026-01-01T00:00:00",
"ISO timestamp for a one-time run (when kind is once).",
),
field(
"delay_seconds",
"form",
"integer",
False,
"60",
"Seconds to wait before a single run (when kind is delay).",
),
field(
"every_seconds",
"form",
"integer",
False,
"300",
"Interval in seconds between runs (when kind is interval).",
),
field(
"max_runs",
"form",
"integer",
False,
"10",
"Optional cap on the number of runs.",
),
],
),
endpoint(
id="containers-instance-schedule-delete",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete",
title="Delete a schedule",
summary="Remove a schedule from an instance.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"sid", "path", "string", True, "SCHEDULE_UID", "Schedule uid."
),
],
),
endpoint(
id="containers-admin-create",
method="POST",
path="/admin/containers/create",
title="Admin create instance",
summary="Create an instance from the admin Containers page: project search-select, run-as user, boot language/script, restart policy, start-on-boot, plus the usual options.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
field("name", "form", "string", True, "staging", "Instance name."),
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
field("ports", "form", "string", False, "80", "Port maps; bare container port auto-assigns a host port above 20000."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
field("ingress_port", "form", "integer", False, "8899", "Container port to publish."),
],
),
endpoint(
id="containers-admin-edit",
method="POST",
path="/admin/containers/{uid}/edit",
title="Admin edit instance",
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("run_as_uid", "form", "string", False, "USER_UID", "Run-as user uid (identity + API key only)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code."),
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
field("start_on_boot", "form", "boolean", False, "false", "Force running on container-service boot."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
],
),
endpoint(
id="containers-admin-action",
method="POST",
path="/admin/containers/{uid}/{action}",
title="Admin instance lifecycle",
summary="start, stop, restart, pause, or resume an instance from the admin Containers page (flips desired state).",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("action", "path", "enum", True, "start", "Lifecycle action.", ["start", "stop", "restart", "pause", "resume"]),
],
),
endpoint(
id="containers-admin-sync",
method="POST",
path="/admin/containers/{uid}/sync",
title="Admin bidirectional sync",
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
sample_response={"exported": 3, "imported": 1},
),
endpoint(
id="containers-admin-delete",
method="POST",
path="/admin/containers/{uid}/delete",
title="Admin delete instance",
summary="Soft-delete an instance and mark its container for removal.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
),
endpoint(
id="containers-admin-project-search",
method="GET",
path="/admin/containers/projects/search",
title="Admin project search",
summary="Search projects by title for the admin create form (returns uid, slug, title).",
auth="admin",
params=[
field("q", "query", "string", False, "api", "Title fragment."),
],
sample_response={"results": [{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "title": "My Project"}]},
),
endpoint(
id="containers-admin-user-search",
method="GET",
path="/admin/containers/users/search",
title="Admin run-as user search",
summary="Search users by username for the run-as-user select (returns uid, username).",
auth="admin",
params=[
field("q", "query", "string", False, "alice", "Username fragment."),
],
sample_response={"results": [{"uid": "USER_UID", "username": "alice"}]},
),
],
}

View File

@ -0,0 +1,946 @@
# retoor <retoor@molodetz.nl>
from .._shared import COMMENT_TARGETS, GIST_LANGUAGES, PROJECT_TYPES, endpoint, field
from devplacepy.constants import TOPICS
GROUP = {
"slug": "content",
"title": "Posts, Comments, Projects, Gists & News",
"intro": """
# Posts, Comments, Projects, Gists & News
The core content types. Read endpoints render HTML pages; write endpoints accept form fields
and redirect to the new or updated resource. List fields such as `attachment_uids` are
repeated form keys - upload files first via [Uploads](/docs/uploads.html) and pass the returned
uids here. Engage with this content through [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html).
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="home",
method="GET",
path="/",
title="Home",
summary=(
"The home page. Guests see the marketing splash; authenticated users see a "
"personalized home (welcome, feed shortcut, latest posts, news). It no longer "
"redirects to /feed. The latest-posts section interleaves authors so no two consecutive posts share an author."
),
auth="public",
interactive=True,
),
endpoint(
id="feed-list",
method="GET",
path="/feed",
title="Browse the feed",
summary="The main post feed. Returns an HTML page. Each page interleaves authors so no two consecutive posts share an author.",
auth="public",
interactive=True,
params=[
field(
"tab",
"query",
"enum",
False,
"all",
"Feed selector.",
["all", "trending", "following"],
),
field(
"topic", "query", "enum", False, "", "Filter by topic.", TOPICS
),
field(
"search",
"query",
"string",
False,
"",
"Search post title, content, and author username.",
),
field("before", "query", "string", False, "", "Pagination cursor."),
],
),
endpoint(
id="posts-create",
method="POST",
path="/posts/create",
title="Create a post",
summary="Publish a post, optionally with a poll. Redirects to the new post.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"content",
"form",
"textarea",
True,
"Posted from a script.",
"Body, 10-125000 characters.",
),
field(
"title",
"form",
"string",
False,
"Hello",
"Optional title, up to 500 characters.",
),
field(
"topic", "form", "enum", False, "random", "Post topic.", TOPICS
),
field(
"project_uid",
"form",
"string",
False,
"",
"Attach to a project.",
),
field(
"poll_question",
"form",
"string",
False,
"",
"Optional poll question.",
),
field(
"poll_options",
"form",
"string",
False,
"",
"Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
),
],
notes=["Returns a `302` redirect to `/posts/{slug}` on success."],
),
endpoint(
id="posts-detail",
method="GET",
path="/posts/{post_slug}",
title="View a post",
summary="Render a post with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"post_slug",
"path",
"string",
True,
"POST_SLUG",
"Slug or UID of the post.",
)
],
),
endpoint(
id="posts-edit",
method="POST",
path="/posts/edit/{post_slug}",
title="Edit a post",
summary="Update a post you own, optionally adding a poll if it has none.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"post_slug",
"path",
"string",
True,
"POST_SLUG",
"Slug or UID of the post.",
),
field(
"content",
"form",
"textarea",
True,
"Updated body.",
"Body, 10-125000 characters.",
),
field(
"title",
"form",
"string",
False,
"Updated title",
"Optional title.",
),
field(
"topic", "form", "enum", False, "random", "Post topic.", TOPICS
),
field(
"poll_question",
"form",
"string",
False,
"",
"Optional poll question. Adds a poll only when the post has none.",
),
field(
"poll_options",
"form",
"string",
False,
"",
"Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
),
],
),
endpoint(
id="posts-delete",
method="POST",
path="/posts/delete/{post_slug}",
title="Delete a post",
summary="Delete a post you own; administrators may delete any user's post. Soft-deleted (hidden everywhere but restorable from admin trash) and cascades its comments and votes.",
auth="user",
destructive=True,
params=[
field(
"post_slug",
"path",
"string",
True,
"POST_SLUG",
"Slug or UID of the post.",
)
],
),
endpoint(
id="comments-create",
method="POST",
path="/comments/create",
title="Create a comment",
summary="Comment on any commentable target. Supports nested replies.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"content",
"form",
"textarea",
True,
"Nice work.",
"Body, 3-1000 characters.",
),
field(
"target_uid",
"form",
"string",
False,
"POST_UID",
"UID of the target (or use post_uid).",
),
field(
"post_uid",
"form",
"string",
False,
"",
"Convenience alias for a post target.",
),
field(
"target_type",
"form",
"enum",
False,
"post",
"Type of the target.",
COMMENT_TARGETS,
),
field(
"parent_uid",
"form",
"string",
False,
"",
"Parent comment UID for a reply.",
),
],
notes=["Either `target_uid` or `post_uid` is required."],
),
endpoint(
id="comments-edit",
method="POST",
path="/comments/edit/{comment_uid}",
title="Edit a comment",
summary="Edit the body of a comment you own. Returns the updated comment.",
auth="user",
encoding="form",
params=[
field(
"comment_uid",
"path",
"string",
True,
"COMMENT_UID",
"UID of the comment.",
),
field(
"content",
"form",
"textarea",
True,
"Edited body.",
"New body, 3-1000 characters.",
),
],
sample_response={
"uid": "COMMENT_UID",
"content": "Edited body.",
"url": "/posts/POST_SLUG#comment-COMMENT_UID",
"updated_at": "2026-06-15T12:00:00+00:00",
},
),
endpoint(
id="comments-delete",
method="POST",
path="/comments/delete/{comment_uid}",
title="Delete a comment",
summary="Delete a comment you own; administrators may delete any user's comment. Soft-deleted (hidden everywhere but restorable from admin trash).",
auth="user",
destructive=True,
params=[
field(
"comment_uid",
"path",
"string",
True,
"COMMENT_UID",
"UID of the comment.",
)
],
),
endpoint(
id="projects-list",
method="GET",
path="/projects",
title="Browse projects",
summary="List projects. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"tab",
"query",
"enum",
False,
"recent",
"Sort selector.",
["recent", "popular", "released"],
),
field(
"search",
"query",
"string",
False,
"",
"Search project title, description, and author username.",
),
field(
"project_type",
"query",
"enum",
False,
"",
"Filter by type.",
PROJECT_TYPES,
),
field("before", "query", "string", False, "", "Pagination cursor."),
],
),
endpoint(
id="projects-detail",
method="GET",
path="/projects/{project_slug}",
title="View a project",
summary="Render a project with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
)
],
),
endpoint(
id="projects-create",
method="POST",
path="/projects/create",
title="Create a project",
summary="Publish a project. Redirects to the new project.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"title",
"form",
"string",
True,
"My Project",
"Title, 1-200 characters.",
),
field(
"description",
"form",
"textarea",
True,
"What it does.",
"Description, 1-5000 characters.",
),
field(
"project_type",
"form",
"enum",
False,
"software",
"Project type.",
PROJECT_TYPES,
),
field(
"status",
"form",
"string",
False,
"In Development",
"Free-form status label.",
),
field(
"platforms",
"form",
"string",
False,
"Linux, Web",
"Comma-separated platforms.",
),
field(
"release_date",
"form",
"string",
False,
"31/12/2026",
"Optional release date in DD/MM/YYYY format.",
),
field(
"demo_date",
"form",
"string",
False,
"31/12/2026",
"Optional demo date in DD/MM/YYYY format.",
),
],
),
endpoint(
id="projects-edit",
method="POST",
path="/projects/edit/{project_slug}",
title="Edit a project",
summary="Update an owned project. Redirects to the project.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"my-project-1a2b3c4d",
"Project slug or uid.",
),
field(
"title",
"form",
"string",
True,
"My Project",
"Title, 1-200 characters.",
),
field(
"description",
"form",
"textarea",
True,
"What it does.",
"Description, 1-5000 characters.",
),
field(
"project_type",
"form",
"enum",
False,
"software",
"Project type.",
PROJECT_TYPES,
),
field(
"status",
"form",
"string",
False,
"In Development",
"Free-form status label.",
),
field(
"platforms",
"form",
"string",
False,
"Linux, Web",
"Comma-separated platforms.",
),
field(
"release_date",
"form",
"string",
False,
"31/12/2026",
"Optional release date in DD/MM/YYYY format.",
),
field(
"demo_date",
"form",
"string",
False,
"31/12/2026",
"Optional demo date in DD/MM/YYYY format.",
),
],
),
endpoint(
id="projects-delete",
method="POST",
path="/projects/delete/{project_slug}",
title="Delete a project",
summary="Delete a project you own; administrators may delete any user's project. Soft-deleted with all of its files (hidden everywhere but restorable from admin trash).",
auth="user",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
)
],
),
endpoint(
id="projects-private",
method="POST",
path="/projects/{project_slug}/private",
title="Set project visibility",
summary="Mark a project you own private or public. Send value=1 for private, value=0 for public. A project you hide as a member stays visible to administrators; a project you hide as an administrator is visible only to you, not to other administrators (this also hides its files and any attached containers).",
auth="user",
encoding="form",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
),
field(
"value",
"form",
"boolean",
True,
"1",
"1 to make the project private, 0 to make it public.",
),
],
),
endpoint(
id="projects-readonly",
method="POST",
path="/projects/{project_slug}/readonly",
title="Set project read-only",
summary="Mark a project you own read-only so all of its files become immutable (no writes, edits, moves, deletes, or uploads succeed), or writable again. Send value=1 for read-only, value=0 for writable.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
),
field(
"value",
"form",
"boolean",
True,
"1",
"1 to make the project read-only, 0 to make it writable.",
),
],
),
endpoint(
id="projects-zip",
method="POST",
path="/projects/{project_slug}/zip",
title="Queue a project zip",
summary="Start a background job that archives the whole project. Returns the job uid and status URL to poll.",
auth="public",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
)
],
sample_response={
"uid": "ZIP_JOB_UID",
"status_url": "/zips/ZIP_JOB_UID",
},
),
endpoint(
id="zips-status",
method="GET",
path="/zips/{uid}",
title="Zip job status",
summary="Poll a zip job. While pending or running download_url is null; once done it points at the archive.",
auth="public",
params=[
field(
"uid",
"path",
"string",
True,
"ZIP_JOB_UID",
"Zip job uid returned when the job was queued.",
)
],
sample_response={
"uid": "ZIP_JOB_UID",
"kind": "zip",
"status": "done",
"preferred_name": "my-project",
"download_url": "/zips/ZIP_JOB_UID/download",
"error": None,
"bytes_in": 20480,
"bytes_out": 8192,
"item_count": 12,
"file_count": 10,
"dir_count": 2,
"created_at": "2026-06-09T10:00:00+00:00",
"completed_at": "2026-06-09T10:00:03+00:00",
},
),
endpoint(
id="zips-download",
method="GET",
path="/zips/{uid}/download",
title="Download a zip archive",
summary="Stream the finished archive as application/zip. Each access extends the retention window.",
auth="public",
interactive=True,
params=[
field(
"uid",
"path",
"string",
True,
"ZIP_JOB_UID",
"Zip job uid of a finished job.",
)
],
),
endpoint(
id="projects-fork",
method="POST",
path="/projects/{project_slug}/fork",
title="Queue a project fork",
summary="Start a background job that copies the whole project into a new project owned by you. Returns the job uid and status URL to poll.",
auth="user",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project to fork.",
),
field(
"title",
"form",
"string",
True,
"My Fork",
"Title for the new forked project.",
),
],
sample_response={
"uid": "FORK_JOB_UID",
"status_url": "/forks/FORK_JOB_UID",
},
),
endpoint(
id="forks-status",
method="GET",
path="/forks/{uid}",
title="Fork job status",
summary="Poll a fork job. While pending or running project_url is null; once done it points at the new project.",
auth="public",
params=[
field(
"uid",
"path",
"string",
True,
"FORK_JOB_UID",
"Fork job uid returned when the job was queued.",
)
],
sample_response={
"uid": "FORK_JOB_UID",
"kind": "fork",
"status": "done",
"preferred_name": "My Fork",
"project_uid": "NEW_PROJECT_UID",
"project_url": "/projects/new-project-slug",
"source_project_uid": "SOURCE_PROJECT_UID",
"error": None,
"item_count": 12,
"created_at": "2026-06-09T10:00:00+00:00",
"completed_at": "2026-06-09T10:00:05+00:00",
},
),
endpoint(
id="gists-list",
method="GET",
path="/gists",
title="Browse gists",
summary="List code gists. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"language",
"query",
"enum",
False,
"",
"Filter by language.",
GIST_LANGUAGES,
),
field(
"user_uid",
"query",
"string",
False,
"",
"Filter by author UID.",
),
field(
"search",
"query",
"string",
False,
"",
"Search gist title, description, and author username.",
),
field("before", "query", "string", False, "", "Pagination cursor."),
],
),
endpoint(
id="gists-detail",
method="GET",
path="/gists/{gist_slug}",
title="View a gist",
summary="Render a gist with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"gist_slug",
"path",
"string",
True,
"GIST_SLUG",
"Slug or UID of the gist.",
)
],
),
endpoint(
id="gists-create",
method="POST",
path="/gists/create",
title="Create a gist",
summary="Publish a code snippet. Redirects to the new gist.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"title",
"form",
"string",
True,
"Quick sort",
"Title, 1-200 characters.",
),
field(
"source_code",
"form",
"textarea",
True,
"print('hello')",
"Source, 1-400000 characters.",
),
field(
"language",
"form",
"enum",
False,
"python",
"Syntax language.",
GIST_LANGUAGES,
),
field(
"description",
"form",
"string",
False,
"",
"Optional description.",
),
],
),
endpoint(
id="gists-edit",
method="POST",
path="/gists/edit/{gist_slug}",
title="Edit a gist",
summary="Update a gist you own.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"gist_slug",
"path",
"string",
True,
"GIST_SLUG",
"Slug or UID of the gist.",
),
field(
"title",
"form",
"string",
True,
"Quick sort",
"Title, 1-200 characters.",
),
field(
"source_code",
"form",
"textarea",
True,
"print('hi')",
"Source, 1-400000 characters.",
),
field(
"language",
"form",
"enum",
False,
"python",
"Syntax language.",
GIST_LANGUAGES,
),
field(
"description",
"form",
"string",
False,
"",
"Optional description.",
),
],
),
endpoint(
id="gists-delete",
method="POST",
path="/gists/delete/{gist_slug}",
title="Delete a gist",
summary="Delete a gist you own; administrators may delete any user's gist. Soft-deleted (hidden everywhere but restorable from admin trash).",
auth="user",
destructive=True,
params=[
field(
"gist_slug",
"path",
"string",
True,
"GIST_SLUG",
"Slug or UID of the gist.",
)
],
),
endpoint(
id="news-list",
method="GET",
path="/news",
title="Browse news",
summary="Curated developer news. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"before",
"query",
"string",
False,
"",
"Pagination cursor (synced_at).",
)
],
),
endpoint(
id="news-detail",
method="GET",
path="/news/{news_slug}",
title="View a news article",
summary="Render a news article with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"news_slug",
"path",
"string",
True,
"NEWS_SLUG",
"Slug or UID of the article.",
)
],
),
],
}

View File

@ -0,0 +1,152 @@
# retoor <retoor@molodetz.nl>
GROUP = {
"slug": "conventions",
"title": "Conventions & Errors",
"intro": """
# Conventions & Errors
Shared rules that apply to every endpoint in this reference.
## Base URL
Every example uses your current host:
```
{{ base }}
```
## Authentication
Each endpoint is tagged **public**, **user**, or **admin**. Authenticate user and admin
endpoints with any of the four methods in [Authentication](/docs/authentication.html): the
`session` cookie, an `X-API-KEY` header, a `Bearer` token, or HTTP Basic credentials. The
interactive panels on this site pre-fill your own API key, so you can run user-level calls
immediately.
## Request bodies
POST endpoints accept `application/x-www-form-urlencoded` form fields (the same fields the
website submits). File uploads use `multipart/form-data`. A small number of endpoints accept
a JSON body; those are noted explicitly.
## HTML or JSON (content negotiation)
**Every** endpoint that renders a page or returns a redirect can also answer in JSON - the
website keeps working exactly as before, and automation gets structured data from the same
URLs. A request is served JSON when it sends either of:
- `Accept: application/json`
- `Content-Type: application/json`
A normal browser navigation (`Accept: text/html`) always receives HTML, so nothing existing
changes. Responses are defined by Pydantic models, so each page returns the same data the
template renders.
**Page reads** (GET) return the page payload as a JSON object (lists include a `next_cursor`
for pagination; detail pages embed author, comments, reactions, poll, and attachments).
**Actions** (the form POSTs: create / edit / delete / follow / send / mark-read …) return a
uniform envelope instead of a `302` redirect:
```json
{ "ok": true, "redirect": "/posts/abc-my-post", "data": { "uid": "…", "slug": "…", "url": "…" } }
```
`data` carries the created/affected resource where applicable, or `null`. Cookies (e.g. the
session set on login/signup) are still set on JSON responses.
**Errors** are JSON too when JSON is requested:
```json
{ "error": { "status": 404, "message": "Not found" } }
```
Validation failures return `422` with `{ "error": "validation", "fields": { "field": ["msg"] } }`.
Unauthenticated JSON requests to a protected endpoint return `401` (browsers are redirected to
the login page instead); non-admins calling an admin endpoint get `403`.
## Trying it here
Every endpoint below has a live panel. Pick the response format (**JSON** by default, or
**HTML** where the endpoint negotiates) and the panel sets the matching `Accept` header on the
request and the generated cURL/JavaScript/Python snippets. The **Expected** tab always shows the
modeled response shape; the **Live response** tab shows the real result after you press
**Send request**.
## AJAX responses (legacy shape)
The [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html) endpoints predate the
envelope and keep their original flat JSON shapes (e.g. `{ "saved": true }`). They return JSON
when the request carries an `X-Requested-With: fetch` header (a subset of the rule above);
without it they `302` redirect, mirroring the browser flow. The interactive panels send the
header for you.
## Pagination
Most list endpoints page with an opaque cursor. Pass the `before` query parameter set to the
`created_at` (or `synced_at`) value of the last item you received to fetch the next page; the
JSON payload returns a `next_cursor` to use as the next `before`. This covers the feed, the
post/project/gist/news lists, notifications, and saved bookmarks.
The follower and following lists are the exception: `GET /profile/{username}/followers` and
`GET /profile/{username}/following` use classic page-based pagination via the `page` query
parameter (25 per page), not a cursor.
## Identifiers
Posts, projects, gists, and news articles accept either their slug or their bare UUID in the
path. Slugs embed the first eight characters of the UUID.
## Dates
All dates rendered to users are `DD/MM/YYYY`. Timestamps in stored records are ISO-8601 UTC.
## Status codes
| Code | Meaning |
|------|---------|
| `200` | Success (JSON or HTML) |
| `201` | Resource created (uploads) |
| `302` | Redirect (browser-style success for form posts) |
| `400` | Invalid request body or parameters |
| `401` | Credentials supplied but invalid |
| `403` | Authenticated but not allowed |
| `404` | Resource not found |
| `413` | Upload exceeds the configured size limit (see [Uploads](/docs/uploads.html)) |
| `415` | Upload file type not allowed (see [Uploads](/docs/uploads.html)) |
| `422` | Form/body validation failed (JSON clients) |
| `429` | Rate limit exceeded (see Rate limiting) |
| `503` | Maintenance mode |
## Rate limiting
Mutating requests (`POST`/`PUT`/`DELETE`/`PATCH`) are rate limited per client IP over a
rolling window; reads are not limited. The limit and window are configurable by an
administrator (defaults: 60 requests per 60 seconds). When you exceed the limit you receive a
`429` whose `Retry-After` header gives the number of seconds to wait before retrying. The
OpenAI gateway (`/openai/...`) is exempt.
## Troubleshooting
**I get HTML back instead of JSON.** Send `Accept: application/json` (or
`Content-Type: application/json` on a body). A request is only served JSON when it asks for it
and does not also accept `text/html`; a normal browser navigation always gets HTML.
`X-Requested-With: fetch` is **not** a general JSON switch - it only applies to the legacy
engagement actions (votes, reactions, bookmarks, polls).
**An action returns `302` instead of the JSON envelope.** Same cause: the request did not ask
for JSON. Add the `Accept: application/json` header and the action returns
`{ "ok": true, "redirect": "...", "data": {...} }` instead of redirecting.
**A protected endpoint redirects me to the login page.** Browser-style (HTML) requests to a
`user`/`admin` endpoint without valid credentials are redirected to login; the same request
with `Accept: application/json` returns `401` instead. Non-admins calling an `admin` endpoint
get `403` (JSON) or a redirect to the feed (HTML).
**An upload is rejected with `413` or `415`.** `413` means the file exceeds the configured
size limit; `415` means the file type is not in the allowed list. Both limits are set by an
administrator.
""",
"endpoints": [],
}

View File

@ -0,0 +1,195 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "game",
"title": "Code Farm",
"intro": """
# Code Farm
The Code Farm is a cooperative idle game. Each member owns a farm of plots, plants software
projects that build over real time, harvests them for coins and XP, upgrades their CI tier for
faster builds, and waters other members' growing builds to speed them up and earn coins.
All endpoints negotiate HTML or JSON. The action endpoints return the full farm state so a
client can refresh without a second request.
""",
"endpoints": [
endpoint(
id="game-home",
method="GET",
path="/game",
title="Code Farm page",
summary="The player's own farm: HUD, plot grid, shop, and leaderboard.",
auth="user",
negotiation=True,
sample_response={"ok": True, "farm": {"coins": 50, "level": 1, "plots": []}},
),
endpoint(
id="game-state",
method="GET",
path="/game/state",
title="Farm state",
summary="The signed-in player's full farm state as JSON.",
auth="user",
sample_response={
"ok": True,
"farm": {
"coins": 50,
"level": 1,
"ci_tier": 1,
"plot_count": 4,
"plots": [{"slot": 0, "state": "empty"}],
"crops": [{"key": "python", "name": "Python Script", "cost": 15}],
},
},
),
endpoint(
id="game-leaderboard",
method="GET",
path="/game/leaderboard",
title="Farm leaderboard",
summary="Top farmers ranked by level, XP, and harvests.",
auth="public",
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4}]},
),
endpoint(
id="game-view-farm",
method="GET",
path="/game/farm/{username}",
title="View a farm",
summary="Another player's farm, with water controls on growing builds.",
auth="public",
negotiation=True,
params=[field("username", "path", "string", True, "alice", "Farm owner's username.")],
sample_response={"farm": {"owner_username": "alice", "is_owner": False, "plots": []}},
),
endpoint(
id="game-plant",
method="POST",
path="/game/plant",
title="Plant a crop",
summary="Plant a crop in an empty plot. Costs the crop's coin price.",
auth="user",
params=[
field("slot", "form", "integer", True, "0", "Plot slot index."),
field("crop", "form", "string", True, "python", "Crop key."),
],
sample_response={"ok": True, "farm": {"coins": 35}},
),
endpoint(
id="game-harvest",
method="POST",
path="/game/harvest",
title="Harvest a build",
summary="Harvest a finished build for coins and XP.",
auth="user",
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
sample_response={"ok": True, "farm": {"coins": 86}},
),
endpoint(
id="game-buy-plot",
method="POST",
path="/game/buy-plot",
title="Buy a plot",
summary="Unlock a new plot. Cost doubles per extra plot.",
auth="user",
sample_response={"ok": True, "farm": {"plot_count": 5}},
),
endpoint(
id="game-upgrade",
method="POST",
path="/game/upgrade",
title="Upgrade CI",
summary="Upgrade the farm CI tier for faster builds.",
auth="user",
sample_response={"ok": True, "farm": {"ci_tier": 2}},
),
endpoint(
id="game-water",
method="POST",
path="/game/farm/{username}/water",
title="Water a build",
summary="Water another player's growing build to speed it up and earn coins.",
auth="user",
params=[
field("username", "path", "string", True, "alice", "Farm owner's username."),
field("slot", "form", "integer", True, "0", "Plot slot index."),
],
sample_response={"farm": {"owner_username": "alice"}},
),
endpoint(
id="game-steal",
method="POST",
path="/game/farm/{username}/steal",
title="Steal a build",
summary="Steal another player's ready build once its protection window has passed; you receive half the build's coin value. Limited to once per hour per neighbour.",
auth="user",
params=[
field("username", "path", "string", True, "alice", "Farm owner's username."),
field("slot", "form", "integer", True, "0", "Plot slot index."),
],
sample_response={"farm": {"owner_username": "alice"}, "stole_coins": 18},
),
endpoint(
id="game-fertilize",
method="POST",
path="/game/fertilize",
title="Fertilize a build",
summary="Spend coins to halve a growing build's remaining time. The cost scales with the build's realized harvest value, so fertilizing is a pure time-skip and never a profit at any prestige.",
auth="user",
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
sample_response={"ok": True, "farm": {"coins": 12}},
),
endpoint(
id="game-daily",
method="POST",
path="/game/daily",
title="Claim daily bonus",
summary="Claim the once-per-day coin bonus; consecutive days grow a streak.",
auth="user",
sample_response={"ok": True, "farm": {"streak": 3, "coins": 94}},
),
endpoint(
id="game-perk",
method="POST",
path="/game/perk",
title="Upgrade a perk",
summary="Upgrade a permanent perk: yield, growth, discount, or xp.",
auth="user",
params=[field("perk", "form", "string", True, "growth", "Perk key.")],
sample_response={"ok": True, "farm": {"coins": 0}},
),
endpoint(
id="game-quests-claim",
method="POST",
path="/game/quests/claim",
title="Claim a quest",
summary="Claim a completed daily quest reward by its kind.",
auth="user",
params=[field("quest", "form", "string", True, "harvest", "Quest kind.")],
sample_response={"ok": True, "farm": {"coins": 130}},
),
endpoint(
id="game-prestige",
method="POST",
path="/game/prestige",
title="Refactor (prestige)",
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades.",
auth="user",
destructive=True,
sample_response={"ok": True, "farm": {"prestige": 1}},
),
endpoint(
id="game-legacy",
method="POST",
path="/game/legacy",
title="Buy a Legacy upgrade",
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest, multiplier, speed, plots, or defense.",
auth="user",
params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")],
sample_response={"ok": True, "farm": {"stars": 1}},
),
],
}

View File

@ -0,0 +1,195 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "gateway",
"title": "OpenAI Gateway",
"admin": True,
"intro": """
# OpenAI Gateway
An OpenAI-compatible proxy mounted at `/openai/v1`. It forwards requests to a configured
upstream using the gateway's own credentials, so no DevPlace key is required - but an
administrator must enable the `openai` service first. Point any OpenAI-compatible client at
`{{ base }}/openai/v1`.
This gateway is the **single point of truth for AI** on the platform. Every other DevPlace
service (news, bots, Devii) calls it by default instead of an external provider, sends the
generic model name `molodetz`, and authenticates with an internal key that is auto-generated
on first boot. The real provider URLs, models, and keys (DeepSeek, OpenRouter) live only here,
so an operator switches providers or backends in one place. `DEEPSEEK_API_KEY` and
`OPENROUTER_API_KEY` are migrated into the editable settings on boot and the value in use is
shown. Because `Force model` is on by default, the upstream always receives the configured
model regardless of what a client (or `molodetz`) requests.
The gateway also performs **vision** augmentation: when a request includes an image, the gateway
describes the image with a configured vision model and rewrites it to text, so a vision-less
upstream still works. The vision model, URL, and key are configured alongside the other gateway
settings.
The gateway additionally serves **text embeddings** at `/openai/v1/embeddings`. Clients request the
generic model `molodetz~embed`, which the gateway maps to the configured embedding model (OpenRouter's
Qwen3 8B embedding model by default). Usage and cost are tracked per call exactly like chat and vision.
## Model routing and providers
On top of the single default upstream above, an administrator can register additional named
**providers** and map any number of requested **model names** onto them, so one gateway can front
many models across many backends. A model route binds a source model name (what a client sends) to a
target provider and upstream model, and carries:
- its **own pricing economy** (input, output, and cache-hit / cache-miss prices per million tokens),
used to compute that call's cost when the upstream returns no native cost;
- an optional **vision model**, which turns on the image-to-text merge for that route (so a text-only
model can answer about images);
- an optional **context window** used for the context-utilization header.
Resolution is transparent to clients: when the requested `model` matches an active route, the gateway
forwards to that route's provider and target model and meters the call against the route's economy.
When it matches no route, the request falls through to the default upstream unchanged (so `molodetz`,
`molodetz~embed`, and any existing client keep working exactly as before). Providers and routes are
managed by administrators on the **Gateway** page (`/admin/gateway`).
## Per-call cost and usage headers
Every gateway response - chat, embeddings, and passthrough, on both success and error - carries
`X-Gateway-*` response headers describing that single call, so a client can read its own token usage
and dollar cost directly from the response with no extra request:
| Header | Meaning |
|--------|---------|
| `X-Gateway-Model` | Upstream model actually used for the call |
| `X-Gateway-Backend` | Backend that served it: `chat`, `embed`, or passthrough |
| `X-Gateway-Prompt-Tokens` | Input (prompt) tokens |
| `X-Gateway-Completion-Tokens` | Output (completion) tokens |
| `X-Gateway-Total-Tokens` | Total tokens (prompt + completion) |
| `X-Gateway-Cache-Hit-Tokens` | Prompt tokens served from the upstream prompt cache |
| `X-Gateway-Cache-Miss-Tokens` | Prompt tokens not served from cache |
| `X-Gateway-Reasoning-Tokens` | Reasoning tokens, when the model reports them |
| `X-Gateway-Cost-USD` | Total cost of the call in US dollars |
| `X-Gateway-Input-Cost-USD` | Input portion of the cost in US dollars |
| `X-Gateway-Output-Cost-USD` | Output portion of the cost in US dollars |
| `X-Gateway-Cost-Native` | `1` if the dollar cost is the upstream's own reported cost, `0` if computed from the configured per-million pricing |
| `X-Gateway-Tokens-Per-Second` | Output tokens per second for the call |
| `X-Gateway-Upstream-Latency-Ms` | Upstream round-trip latency in milliseconds |
| `X-Gateway-Total-Latency-Ms` | Full end-to-end gateway time for the call in milliseconds |
| `X-Gateway-Gateway-Overhead-Ms` | Gateway processing time minus the upstream and queue wait, in milliseconds |
| `X-Gateway-Queue-Wait-Ms` | Time spent waiting on the concurrency semaphore before dispatch, in milliseconds |
| `X-Gateway-Connect-Ms` | Upstream connection establishment time in milliseconds |
| `X-Gateway-Context-Window` | The model's context window in tokens, when known |
| `X-Gateway-Context-Utilization` | Total tokens as a fraction of the context window, when known |
Dollar costs use the upstream's native `cost` field when it returns one
(`X-Gateway-Cost-Native: 1`); otherwise they are computed from the per-million prices of the matched
model route, falling back to the prices configured on the `openai` service when no route matches. The
one denied path that makes no upstream call (embeddings disabled) returns no usage headers.
Administrators enable and configure this gateway under [Background Services](/docs/services.html)
(the `openai` service).
The gateway is exempt from rate limiting, but every other endpoint follows the shared
[Conventions & Errors](/docs/conventions.html); see [Authentication](/docs/authentication.html)
for signing DevPlace's own requests.
""",
"endpoints": [
endpoint(
id="gateway-chat",
method="POST",
path="/openai/v1/chat/completions",
title="Chat completions",
summary="OpenAI-compatible chat completion. Supports streaming.",
auth="user",
encoding="json",
params=[
field(
"model",
"json",
"string",
False,
"gpt-4o-mini",
"Model id. When it matches a configured model route the gateway forwards to that route's provider and upstream model; otherwise it uses the default upstream model.",
),
field(
"messages",
"json",
"string",
True,
'[{"role":"user","content":"Hello"}]',
"Chat messages array.",
),
field(
"stream",
"json",
"string",
False,
"false",
"Set true for a streamed SSE response.",
),
],
notes=[
"Returns `503` when the gateway service is not running.",
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above), including the streamed SSE response.",
"If `model` matches a configured model route it is forwarded to that route's provider, upstream model, and per-model pricing (with an optional vision model); otherwise it falls through to the default upstream (see Model routing and providers above).",
],
),
endpoint(
id="gateway-embeddings",
method="POST",
path="/openai/v1/embeddings",
title="Embeddings",
summary="OpenAI-compatible text embeddings. Request model molodetz~embed.",
auth="user",
encoding="json",
params=[
field(
"model",
"json",
"string",
False,
"molodetz~embed",
"Embedding model id; the gateway maps molodetz~embed to the configured model, or to a matching embed model route's provider and target model.",
),
field(
"input",
"json",
"string",
True,
'"some text to embed"',
"String or array of strings to embed.",
),
field(
"dimensions",
"json",
"string",
False,
"4096",
"Optional output vector size (Matryoshka, 32-4096).",
),
],
notes=[
"Returns `503` when the gateway service is not running or embeddings are disabled.",
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).",
],
),
endpoint(
id="gateway-passthrough",
method="POST",
path="/openai/v1/{path}",
title="Passthrough",
summary="Any other /v1 path is forwarded to the upstream as-is.",
auth="user",
interactive=False,
params=[
field(
"path",
"path",
"string",
True,
"models",
"Upstream API path after /v1/.",
)
],
),
],
}

View File

@ -0,0 +1,298 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "issues",
"title": "Issue Reports",
"intro": """
# Issue Reports
The issue tracker is a full integration with a Gitea repository. The listing and detail views read
issues straight from Gitea with their live status, and a report you file is first rewritten by the
internal AI service into a consistent ticket, then posted to Gitea as an issue. The original
reporter is notified when a developer replies or the status changes, and a comment posted here is
pushed to Gitea and attributed to your account.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="issues-list",
method="GET",
path="/issues",
title="List issue tickets",
summary="Render the issue board from Gitea, paginated and filterable by state.",
auth="public",
interactive=True,
params=[
field(
"state",
"query",
"string",
False,
"open",
"Filter: open (default), closed, or all.",
),
field("page", "query", "integer", False, "1", "1-based page."),
],
),
endpoint(
id="issues-create",
method="POST",
path="/issues/create",
title="Report an issue",
summary="Enqueue an issue report. It is enhanced by AI and filed on the tracker.",
auth="user",
encoding="form",
ajax=True,
destructive=True,
params=[
field(
"title",
"form",
"string",
True,
"Login button misaligned",
"Title, 1-200 characters.",
),
field(
"description",
"form",
"textarea",
True,
"Steps to reproduce...",
"Description, 1-5000 characters.",
),
],
notes=["Returns a job uid and status_url. Poll the status_url until status is done to get the issue number."],
sample_response={
"uid": "JOB_UID",
"status_url": "/issues/jobs/JOB_UID",
},
),
endpoint(
id="issues-job",
method="GET",
path="/issues/jobs/{uid}",
title="Issue filing job status",
summary="Poll the filing job; the result carries the new issue number and url.",
auth="public",
ajax=True,
params=[field("uid", "path", "string", True, "JOB_UID", "Job uid.")],
sample_response={
"uid": "JOB_UID",
"kind": "issue_create",
"status": "done",
"number": 42,
"issue_url": "/issues/42",
"enhanced": True,
"error": None,
"created_at": "2026-06-12T09:00:00+00:00",
"completed_at": "2026-06-12T09:00:03+00:00",
},
),
endpoint(
id="issues-planning-queue",
method="POST",
path="/issues/planning",
title="Generate a tickets planning report",
summary="Enqueue a phased markdown implementation document for open tickets, with each ticket's full description reproduced verbatim (inline plus a Source Tickets appendix) alongside implementation steps and acceptance criteria. Admin only.",
auth="admin",
encoding="form",
ajax=True,
params=[field("numbers", "form", "string", False, "1,4,7", "Comma-separated issue numbers to include. Omit to plan every open ticket.")],
notes=["Returns a job uid and status_url. Poll the status_url until status is done to read the markdown and download it.", "Provide 'numbers' to plan only a chosen subset of open tickets; omitting it plans all open tickets."],
sample_response={
"uid": "PLANNING_JOB_UID",
"status_url": "/issues/planning/PLANNING_JOB_UID",
},
),
endpoint(
id="issues-planning-status",
method="GET",
path="/issues/planning/{uid}",
title="Planning report job status",
summary="Poll the planning job; the result carries the rendered markdown and the download URL. Admin only.",
auth="admin",
ajax=True,
params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
sample_response={
"uid": "PLANNING_JOB_UID",
"kind": "planning",
"status": "done",
"download_url": "/issues/planning/PLANNING_JOB_UID/download",
"markdown": "# Open Tickets Implementation Plan\n\n...",
"ai_used": True,
"issue_count": 12,
"bytes_out": 4096,
"error": None,
"created_at": "2026-06-15T09:00:00+00:00",
"completed_at": "2026-06-15T09:00:05+00:00",
},
),
endpoint(
id="issues-planning-download",
method="GET",
path="/issues/planning/{uid}/download",
title="Download the planning report",
summary="Download the generated planning report as a markdown file. Admin only.",
auth="admin",
interactive=True,
params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
),
endpoint(
id="issues-detail",
method="GET",
path="/issues/{number}",
title="View an issue ticket",
summary="Render a Gitea issue and its comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number.")
],
),
endpoint(
id="issues-comment",
method="POST",
path="/issues/{number}/comment",
title="Comment on an issue",
summary="Post a comment to the Gitea issue, attributed to the current user.",
auth="user",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field(
"body",
"form",
"textarea",
True,
"I can reproduce this on mobile.",
"Comment, 1-5000 characters.",
),
],
),
endpoint(
id="issues-status",
method="POST",
path="/issues/{number}/status",
title="Change an issue status",
summary="Open or close the Gitea issue. Admin only.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field(
"status",
"form",
"string",
True,
"closed",
"New status: open or closed.",
),
],
),
endpoint(
id="issues-attachments-list",
method="GET",
path="/issues/{number}/attachments",
title="List issue attachments",
summary="Return the files attached to an issue ticket.",
auth="public",
params=[
field("number", "path", "integer", True, "12", "Issue number."),
],
),
endpoint(
id="issues-attachments-add",
method="POST",
path="/issues/{number}/attachments",
title="Attach files to an issue",
summary=(
"Link already-uploaded files (from /uploads/upload) to an open issue. The "
"files are also mirrored to the Gitea tracker. Allowed only while the issue is open."
),
auth="user",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field(
"attachment_uids",
"form",
"string",
True,
"a1b2c3d4,e5f6g7h8",
"Comma separated attachment uids returned by /uploads/upload.",
),
],
notes=[
"Only the open issue accepts changes; a closed issue returns 409.",
"You can only link your own uploads unless you are an administrator.",
],
),
endpoint(
id="issues-attachments-delete",
method="DELETE",
path="/issues/{number}/attachments/{uid}",
title="Delete an issue attachment",
summary=(
"Soft-delete a file from an open issue (owner or administrator) and remove it "
"from the tracker."
),
auth="user",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field("uid", "path", "string", True, "a1b2c3d4", "Attachment uid."),
],
),
endpoint(
id="issues-comment-attachments-add",
method="POST",
path="/issues/{number}/comments/{cid}/attachments",
title="Attach files to an issue comment",
summary=(
"Link already-uploaded files to an issue comment (issue must be open). Mirrored "
"to the Gitea comment."
),
auth="user",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field("cid", "path", "integer", True, "1", "Gitea comment id."),
field(
"attachment_uids",
"form",
"string",
True,
"a1b2c3d4",
"Comma separated attachment uids returned by /uploads/upload.",
),
],
),
endpoint(
id="issues-comment-attachments-delete",
method="DELETE",
path="/issues/{number}/comments/{cid}/attachments/{uid}",
title="Delete an issue comment attachment",
summary=(
"Soft-delete a file from an issue comment (owner or administrator) and remove it "
"from the tracker."
),
auth="user",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field("cid", "path", "integer", True, "1", "Gitea comment id."),
field("uid", "path", "string", True, "a1b2c3d4", "Attachment uid."),
],
),
],
}

View File

@ -0,0 +1,61 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "lookups",
"title": "Search & Lookups",
"intro": """
# Search & Lookups
Type-ahead lookups that power mentions and the message composer. Both return JSON and accept
a single `q` query parameter. These feed [Messaging](/docs/messaging.html) (the recipient
composer) and [Profiles & Social Graph](/docs/profiles.html) (mentions and user pages).
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="search-users",
method="GET",
path="/profile/search",
title="Search users",
summary="Find up to ten users whose username matches a query.",
auth="user",
params=[
field(
"q",
"query",
required=True,
example="al",
description="Partial username to match.",
)
],
sample_response={
"results": [{"uid": "8f14e45f-...", "username": "alice_test"}]
},
),
endpoint(
id="search-message-recipients",
method="GET",
path="/messages/search",
title="Search message recipients",
summary="Like user search, but excludes yourself; used by the message composer.",
auth="user",
params=[
field(
"q",
"query",
required=True,
example="bo",
description="Partial username to match.",
)
],
sample_response={
"results": [{"uid": "0cc175b9-...", "username": "bob_test"}]
},
),
],
}

View File

@ -0,0 +1,75 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "messaging",
"title": "Messaging",
"intro": """
# Messaging
Direct messages between users. The inbox renders HTML; sending uses form fields. Look up
recipients with [Search & Lookups](/docs/lookups.html) and attach files via [Uploads](/docs/uploads.html).
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="messages-inbox",
method="GET",
path="/messages",
title="Open the inbox",
summary="Render conversations. Returns an HTML page.",
auth="user",
interactive=True,
params=[
field(
"with_uid",
"query",
"string",
False,
"",
"Open a specific conversation by user UID.",
),
field(
"search",
"query",
"string",
False,
"",
"Jump to a conversation by username.",
),
],
),
endpoint(
id="messages-send",
method="POST",
path="/messages/send",
title="Send a message",
summary="Send a direct message to a user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"content",
"form",
"textarea",
True,
"Hello there.",
"Body, 1-2000 characters.",
),
field(
"receiver_uid",
"form",
"string",
True,
"RECEIVER_UID",
"Recipient user UID.",
),
],
),
],
}

View File

@ -0,0 +1,89 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "notifications",
"title": "Notifications",
"intro": """
# Notifications
Read your notification feed and mark items read. The unread counts endpoint backs the badges
in the navigation bar. Deliver these to the browser with [Web Push](/docs/push.html).
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="notifications-list",
method="GET",
path="/notifications",
title="View notifications",
summary="Render your notifications. Returns an HTML page.",
auth="user",
interactive=True,
params=[
field("before", "query", "string", False, "", "Pagination cursor.")
],
),
endpoint(
id="notifications-counts",
method="GET",
path="/notifications/counts",
title="Unread counts",
summary="Unread notification and message counts.",
auth="public",
notes=[
'Guests receive `{ "notifications": 0, "messages": 0 }` instead of an error, so the navigation badge works before login.'
],
sample_response={"notifications": 2, "messages": 1},
),
endpoint(
id="notifications-open",
method="GET",
path="/notifications/open/{notification_uid}",
title="Open a notification",
summary="Mark a notification read and redirect to its target.",
auth="user",
interactive=False,
params=[
field(
"notification_uid",
"path",
"string",
True,
"NOTIFICATION_UID",
"UID of the notification.",
)
],
),
endpoint(
id="notifications-mark-read",
method="POST",
path="/notifications/mark-read/{notification_uid}",
title="Mark one read",
summary="Mark a single notification as read.",
auth="user",
params=[
field(
"notification_uid",
"path",
"string",
True,
"NOTIFICATION_UID",
"UID of the notification.",
)
],
),
endpoint(
id="notifications-mark-all-read",
method="POST",
path="/notifications/mark-all-read",
title="Mark all read",
summary="Mark every notification as read.",
auth="user",
),
],
}

View File

@ -0,0 +1,683 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "profiles",
"title": "Profiles & Social Graph",
"intro": """
# Profiles & Social Graph
Profile data, the follow graph, the leaderboard, and avatar generation. Find users with
[Search & Lookups](/docs/lookups.html); follows generate entries in [Notifications](/docs/notifications.html).
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="my-profile",
method="GET",
path="/profile",
title="View your own profile",
summary="Render the signed-in user's own profile, in the exact format of GET /profile/{username}. Requires authentication.",
auth="user",
interactive=True,
params=[
field(
"tab",
"query",
"enum",
False,
"posts",
"Profile tab.",
["posts", "activity", "followers", "following", "media"],
),
],
),
endpoint(
id="profile-detail",
method="GET",
path="/profile/{username}",
title="View a profile",
summary="Render a user profile. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"username",
"path",
"string",
True,
"{{ username }}",
"Target username.",
),
field(
"tab",
"query",
"enum",
False,
"posts",
"Profile tab.",
["posts", "activity", "followers", "following", "media"],
),
],
),
endpoint(
id="profile-update",
method="POST",
path="/profile/update",
title="Update your profile",
summary="Update your own bio and links.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"bio",
"form",
"textarea",
False,
"Building things.",
"Bio, up to 500 characters.",
),
field(
"location",
"form",
"string",
False,
"Earth",
"Location, up to 200 characters.",
),
field("git_link", "form", "string", False, "", "Git profile URL."),
field(
"website", "form", "string", False, "", "Personal website URL."
),
],
),
endpoint(
id="profile-ai-correction",
method="POST",
path="/profile/{username}/ai-correction",
title="Configure AI content correction",
summary="Enable or disable automatic AI rewriting of your prose and set the correction instruction. Opt-in, default off. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"enabled",
"form",
"boolean",
False,
"true",
"true to enable background AI correction, false to disable it.",
),
field(
"sync",
"form",
"boolean",
False,
"false",
"true to apply the correction synchronously (the save waits), false for background.",
),
field(
"prompt",
"form",
"textarea",
False,
"Leave literary as is, only do punctuation and casing",
"Correction instruction, up to 2000 characters.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/bob_test",
"data": {
"url": "/profile/bob_test",
"enabled": True,
"sync": False,
"prompt": "Leave literary as is, only do punctuation and casing",
},
},
),
endpoint(
id="profile-ai-modifier",
method="POST",
path="/profile/{username}/ai-modifier",
title="Configure the AI modifier",
summary="Enable or disable the inline '@ai <instruction>' modifier on your prose and set its prompt. Enabled by default, applied synchronously by default. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"enabled",
"form",
"boolean",
False,
"true",
"true to enable the AI modifier, false to disable it.",
),
field(
"sync",
"form",
"boolean",
False,
"true",
"true to apply the modification synchronously (the save waits), false for background.",
),
field(
"prompt",
"form",
"textarea",
False,
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`",
"Modifier instruction, up to 2000 characters.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/bob_test",
"data": {
"url": "/profile/bob_test",
"enabled": True,
"sync": True,
"prompt": "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`",
},
},
),
endpoint(
id="profile-telegram",
method="POST",
path="/profile/{username}/telegram",
title="Pair or unpair Telegram",
summary="Request a single-use Telegram pairing code, or unpair the connected account. Send action=request (default) to receive a code valid for a few minutes, or action=unpair to disconnect. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"action",
"form",
"string",
False,
"request",
"Either request to issue a pairing code, or unpair to disconnect Telegram.",
),
],
sample_response={
"ok": True,
"paired": False,
"code": "1234",
"expires_at": "2026-06-18T12:05:00+00:00",
"ttl_minutes": 5,
},
),
endpoint(
id="profile-regenerate-key",
method="POST",
path="/profile/regenerate-api-key",
title="Regenerate your API key",
summary="Issue a new API key and invalidate the current one.",
auth="user",
interactive=False,
destructive=True,
notes=[
"> Running this invalidates the key these documentation panels use. Do it from your "
"[profile page](/profile/{{ username }}) instead, then reload these docs.",
],
sample_response={"api_key": "NEW_UUID"},
),
endpoint(
id="profile-regenerate-avatar",
method="POST",
path="/profile/{username}/regenerate-avatar",
title="Regenerate a user avatar",
summary="Replace the user's avatar with a freshly generated random one.",
auth="user",
interactive=False,
destructive=True,
params=[
field(
"username",
"path",
required=True,
description="Profile owner. Allowed for the owner or any admin.",
),
],
notes=[
"> Irreversible: the previous avatar is gone for good and cannot be brought back.",
],
sample_response={
"ok": True,
"data": {
"url": "/profile/{{ username }}",
"avatar_seed": "NEW_UUID",
"avatar_url": "/avatar/multiavatar/NEW_UUID?size=80",
},
},
),
endpoint(
id="profile-customization-global",
method="POST",
path="/profile/{username}/customization/global",
title="Toggle site-wide customizations",
summary="Show or suppress your site-wide custom CSS and JS without deleting it. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"value",
"form",
"boolean",
False,
"0",
"1 to show your site-wide customizations, 0 to suppress them.",
),
],
),
endpoint(
id="profile-customization-pagetype",
method="POST",
path="/profile/{username}/customization/pagetype",
title="Toggle per-page customizations",
summary="Show or suppress your per-page custom CSS and JS without deleting it. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"value",
"form",
"boolean",
False,
"0",
"1 to show your per-page customizations, 0 to suppress them.",
),
],
),
endpoint(
id="profile-notification-toggle",
method="POST",
path="/profile/{username}/notifications",
title="Toggle a notification preference",
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"notification_type",
"form",
"string",
True,
"vote",
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
),
field(
"channel",
"form",
"string",
True,
"push",
"One of in_app, push or telegram (telegram is off by default and requires a paired Telegram account).",
),
field(
"value",
"form",
"boolean",
False,
"1",
"1 to deliver this notification on this channel, 0 to suppress it.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/{{ username }}?tab=notifications",
"data": {
"notification_type": "vote",
"channel": "push",
"value": False,
},
},
),
endpoint(
id="profile-notification-reset",
method="POST",
path="/profile/{username}/notifications/reset",
title="Reset notification preferences",
summary="Clear all of a user's notification overrides so every type falls back to the platform default. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/{{ username }}?tab=notifications",
},
),
endpoint(
id="media-delete",
method="POST",
path="/media/{uid}/delete",
title="Delete media",
summary="Remove one of your uploaded media attachments. It disappears from your profile Media tab and from any post, project, gist, or other place it was attached. You can delete media you uploaded; administrators may remove any user's media.",
auth="user",
destructive=True,
params=[
field(
"uid",
"path",
"string",
True,
"",
"Attachment uid, taken from the Media tab response.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/{{ username }}?tab=media",
},
),
endpoint(
id="follow-user",
method="POST",
path="/follow/{username}",
title="Follow a user",
summary="Follow another user. Idempotent.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to follow.",
)
],
),
endpoint(
id="unfollow-user",
method="POST",
path="/follow/unfollow/{username}",
title="Unfollow a user",
summary="Stop following a user.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to unfollow.",
)
],
),
endpoint(
id="block-user",
method="POST",
path="/block/{username}",
title="Block a user",
summary="Block a user. Their posts, comments and messages are hidden from you everywhere except their own profile, and they can no longer create notifications for you. Idempotent.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to block.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="unblock-user",
method="POST",
path="/block/unblock/{username}",
title="Unblock a user",
summary="Reverse a block. Their content becomes visible again.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to unblock.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="mute-user",
method="POST",
path="/mute/{username}",
title="Mute a user",
summary="Mute a user so they can no longer create notifications for you. Their content stays visible. Idempotent.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to mute.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="unmute-user",
method="POST",
path="/mute/unmute/{username}",
title="Unmute a user",
summary="Reverse a mute. They can create notifications for you again.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to unmute.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="list-followers",
method="GET",
path="/profile/{username}/followers",
title="List followers",
summary="List the users who follow a profile, 25 per page. Returns JSON.",
auth="public",
interactive=True,
params=[
field(
"username",
"path",
"string",
True,
"{{ username }}",
"Target username.",
),
field(
"page",
"query",
"integer",
False,
"1",
"Page number, 25 per page.",
),
],
sample_response={
"username": "{{ username }}",
"mode": "followers",
"count": 2,
"page": 1,
"total_pages": 1,
"followers": [
{
"uid": "UUID",
"username": "bob_test",
"bio": "Building things.",
"is_following": False,
},
],
},
),
endpoint(
id="list-following",
method="GET",
path="/profile/{username}/following",
title="List following",
summary="List the users a profile follows, 25 per page. Returns JSON.",
auth="public",
interactive=True,
params=[
field(
"username",
"path",
"string",
True,
"{{ username }}",
"Target username.",
),
field(
"page",
"query",
"integer",
False,
"1",
"Page number, 25 per page.",
),
],
sample_response={
"username": "{{ username }}",
"mode": "following",
"count": 1,
"page": 1,
"total_pages": 1,
"following": [
{
"uid": "UUID",
"username": "alice_test",
"bio": "",
"is_following": True,
},
],
},
),
endpoint(
id="leaderboard",
method="GET",
path="/leaderboard",
title="View the leaderboard",
summary="Top contributors by stars. Returns an HTML page.",
auth="public",
interactive=True,
),
endpoint(
id="avatar",
method="GET",
path="/avatar/{style}/{seed}",
title="Generate an avatar",
summary="Deterministic SVG avatar for a seed. Returns an image.",
auth="public",
interactive=False,
params=[
field(
"style",
"path",
"enum",
True,
"multiavatar",
"Avatar style.",
["multiavatar"],
),
field(
"seed",
"path",
"string",
True,
"{{ username }}",
"Seed string, usually a username.",
),
field("size", "query", "int", False, "128", "Pixel size."),
],
),
],
}

View File

@ -0,0 +1,550 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "project-files",
"title": "Project Filesystem",
"intro": """
# Project Filesystem
Each project carries a full virtual filesystem - directories and files - so a project can hold a
complete software project. Reading is public (anyone can browse a project's tree); creating,
editing, uploading, moving and deleting require the project owner. Text files are editable inline;
binary files are uploaded and served from `/static/uploads/project_files/...`.
Paths are relative POSIX paths inside the project (for example `src/main.py`). Parent directories
are created automatically on write, upload and mkdir. Paths containing `..`, null bytes or empty
segments are rejected.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, status codes); see [Authentication](/docs/authentication.html) for the four ways to
sign requests.
""",
"endpoints": [
endpoint(
id="project-files-list",
method="GET",
path="/projects/{project_slug}/files",
title="List a project's files",
summary="Return the flat list of files and directories in a project.",
auth="public",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
)
],
sample_response={
"project": {"uid": "PROJECT_UID", "slug": "PROJECT_SLUG"},
"files": [
{
"path": "src/main.py",
"name": "main.py",
"type": "file",
"is_binary": False,
"size": 42,
}
],
"is_owner": False,
},
),
endpoint(
id="project-files-raw",
method="GET",
path="/projects/{project_slug}/files/raw",
title="Read a project file",
summary="Return one file's metadata and (for text files) its content.",
auth="public",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"query",
"string",
True,
"src/main.py",
"Relative file path inside the project.",
),
],
sample_response={
"path": "src/main.py",
"name": "main.py",
"type": "file",
"is_binary": False,
"mime_type": "text/plain",
"size": 42,
"url": None,
"content": "print('hello')\n",
},
),
endpoint(
id="project-files-write",
method="POST",
path="/projects/{project_slug}/files/write",
title="Write a text file",
summary="Create or overwrite a text file; parent directories are created automatically.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/main.py",
"Relative file path.",
),
field(
"content",
"form",
"textarea",
True,
"print('hello')",
"Full file content (max 400000 chars).",
),
],
notes=["Owner only; non-owners get `403`. Invalid paths return `400`."],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/main.py", "type": "file"},
},
),
endpoint(
id="project-files-lines",
method="GET",
path="/projects/{project_slug}/files/lines",
title="Read a line range",
summary="Read a 1-indexed inclusive line range of a text file. Returns lines plus total_lines for targeting edits.",
auth="public",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"query",
"string",
True,
"src/main.py",
"Relative file path.",
),
field(
"start",
"query",
"integer",
False,
"1",
"First line, 1-indexed (default 1).",
),
field(
"end",
"query",
"integer",
False,
"50",
"Last line inclusive; omit or -1 for end of file.",
),
],
notes=[
"Text files only; binary, directory, or missing paths return `404`."
],
sample_response={
"path": "src/main.py",
"start": 1,
"end": 2,
"total_lines": 2,
"lines": ["import os", "print(os.getcwd())"],
"content": "import os\nprint(os.getcwd())",
},
),
endpoint(
id="project-files-replace-lines",
method="POST",
path="/projects/{project_slug}/files/replace-lines",
title="Replace a line range",
summary="Replace lines start..end (inclusive) with new content; empty content deletes the range. Leaves the rest of the file untouched.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/main.py",
"Relative file path.",
),
field(
"start",
"form",
"integer",
True,
"10",
"First line to replace (1-indexed).",
),
field(
"end",
"form",
"integer",
True,
"12",
"Last line to replace (inclusive).",
),
field(
"content",
"form",
"textarea",
False,
"new code",
"Replacement text (empty deletes the range).",
),
],
notes=[
"Owner only. The preferred way to edit a large file; avoids rewriting the whole file."
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/main.py", "type": "file"},
},
),
endpoint(
id="project-files-insert-lines",
method="POST",
path="/projects/{project_slug}/files/insert-lines",
title="Insert lines",
summary="Insert content before a 1-indexed line. Use at=1 to prepend and at=total_lines+1 to append.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/main.py",
"Relative file path.",
),
field(
"at",
"form",
"integer",
True,
"1",
"Insert before this 1-indexed line.",
),
field(
"content",
"form",
"textarea",
True,
"# header",
"Text to insert.",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/main.py", "type": "file"},
},
),
endpoint(
id="project-files-delete-lines",
method="POST",
path="/projects/{project_slug}/files/delete-lines",
title="Delete a line range",
summary="Delete lines start..end (inclusive) from a text file.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/main.py",
"Relative file path.",
),
field(
"start",
"form",
"integer",
True,
"5",
"First line to delete (1-indexed).",
),
field(
"end",
"form",
"integer",
True,
"7",
"Last line to delete (inclusive).",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/main.py", "type": "file"},
},
),
endpoint(
id="project-files-append",
method="POST",
path="/projects/{project_slug}/files/append",
title="Append to a file",
summary="Append content as new lines at the end of a text file; grow a large file across calls without resending it.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path", "form", "string", True, "log.txt", "Relative file path."
),
field(
"content",
"form",
"textarea",
True,
"next chunk",
"Text to append.",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "log.txt", "type": "file"},
},
),
endpoint(
id="project-files-upload",
method="POST",
path="/projects/{project_slug}/files/upload",
title="Upload a file into a project",
summary="Upload a file into a directory (parents created); text decodes to an editable file, otherwise stored as binary.",
auth="user",
encoding="multipart",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field("file", "form", "file", True, "", "The file to upload."),
field(
"path",
"form",
"string",
False,
"assets",
"Target directory, empty for the root.",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {
"path": "assets/logo.png",
"type": "file",
"is_binary": True,
},
},
),
endpoint(
id="project-files-mkdir",
method="POST",
path="/projects/{project_slug}/files/mkdir",
title="Create a directory",
summary="Create a directory and any missing parents.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/components",
"Relative directory path.",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/components", "type": "dir"},
},
),
endpoint(
id="project-files-move",
method="POST",
path="/projects/{project_slug}/files/move",
title="Move or rename",
summary="Move or rename a file or directory (and its descendants).",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"from_path",
"form",
"string",
True,
"src/old.py",
"Existing path.",
),
field("to_path", "form", "string", True, "src/new.py", "New path."),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/new.py"},
},
),
endpoint(
id="project-files-delete",
method="POST",
path="/projects/{project_slug}/files/delete",
title="Delete a file or directory",
summary="Delete a file, or a directory and everything under it. Project owner or an administrator; soft-deleted (restorable from admin trash). Blocked while the project is read-only.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/old.py",
"Relative path to delete.",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/old.py"},
},
),
endpoint(
id="project-files-zip",
method="POST",
path="/projects/{project_slug}/files/zip",
title="Queue a zip of files",
summary="Archive the whole tree, or a subtree via the path query. Returns the job uid and status URL to poll with /zips/{uid}.",
auth="public",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"query",
"string",
False,
"src",
"Relative file or directory to archive; empty for the whole project.",
),
],
sample_response={
"uid": "ZIP_JOB_UID",
"status_url": "/zips/ZIP_JOB_UID",
},
),
],
}

View File

@ -0,0 +1,66 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "push",
"title": "Web Push",
"intro": """
# Web Push
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
register a `PushSubscription` obtained from the browser's `PushManager`.
There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
to a subscription once its push endpoint reports it as gone. These mirror the in-app
[Notifications](/docs/notifications.html) feed.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="push-key",
method="GET",
path="/push.json",
title="Get the public key",
summary="Return the VAPID public key for subscribing.",
auth="public",
sample_response={"publicKey": "BASE64_VAPID_KEY"},
),
endpoint(
id="push-register",
method="POST",
path="/push.json",
title="Register a subscription",
summary="Register a browser push subscription. Sends a welcome notification.",
auth="user",
encoding="json",
interactive=False,
params=[
field(
"endpoint",
"json",
"string",
True,
"https://fcm.googleapis.com/...",
"Subscription endpoint URL.",
),
field(
"keys",
"json",
"string",
True,
'{"p256dh":"...","auth":"..."}',
"Subscription keys object.",
),
],
notes=[
'The body must be JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.'
],
sample_response={"registered": True},
),
],
}

View File

@ -0,0 +1,10 @@
# retoor <retoor@molodetz.nl>
GROUP = {
"slug": "services",
"title": "Background Services",
"admin": True,
"dynamic": True,
"intro": "",
"endpoints": [],
}

View File

@ -0,0 +1,195 @@
# retoor <retoor@molodetz.nl>
from .._shared import BOOKMARK_TARGETS, REACTION_TARGETS, VOTE_TARGETS, endpoint, field
from devplacepy.constants import REACTION_EMOJI
GROUP = {
"slug": "social-actions",
"title": "Votes, Reactions, Bookmarks & Polls",
"intro": """
# Votes, Reactions, Bookmarks & Polls
Lightweight engagement actions. The POST endpoints here are **toggles** - sending the same
action again removes it. They return JSON when called with `X-Requested-With: fetch` (sent
automatically by the panels below); the [Conventions & Errors](/docs/conventions.html) page
explains that header rule and the response envelope.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="votes-cast",
method="POST",
path="/votes/{target_type}/{target_uid}",
title="Cast or toggle a vote",
summary="Upvote or downvote a target. Re-sending the same value removes the vote.",
auth="user",
ajax=True,
encoding="form",
params=[
field(
"target_type",
"path",
"enum",
True,
"post",
"Type of content being voted on.",
VOTE_TARGETS,
),
field(
"target_uid",
"path",
"string",
True,
"POST_UID",
"UID of the target.",
),
field(
"value",
"form",
"enum",
True,
"1",
"1 to upvote, -1 to downvote.",
["1", "-1"],
),
],
sample_response={"net": 3, "up": 4, "down": 1, "value": 1},
),
endpoint(
id="reactions-toggle",
method="POST",
path="/reactions/{target_type}/{target_uid}",
title="Toggle an emoji reaction",
summary="Add or remove an emoji reaction on a target.",
auth="user",
ajax=True,
encoding="form",
params=[
field(
"target_type",
"path",
"enum",
True,
"post",
"Type of content being reacted to.",
REACTION_TARGETS,
),
field(
"target_uid",
"path",
"string",
True,
"POST_UID",
"UID of the target.",
),
field(
"emoji",
"form",
"enum",
True,
REACTION_EMOJI[0],
"One of the allowed reaction emoji.",
REACTION_EMOJI,
),
],
sample_response={
"counts": {REACTION_EMOJI[0]: 2},
"mine": [REACTION_EMOJI[0]],
},
),
endpoint(
id="bookmarks-toggle",
method="POST",
path="/bookmarks/{target_type}/{target_uid}",
title="Toggle a bookmark",
summary="Save or unsave a target to your bookmarks.",
auth="user",
ajax=True,
encoding="none",
params=[
field(
"target_type",
"path",
"enum",
True,
"post",
"Type of content to bookmark.",
BOOKMARK_TARGETS,
),
field(
"target_uid",
"path",
"string",
True,
"POST_UID",
"UID of the target.",
),
],
sample_response={"saved": True},
),
endpoint(
id="bookmarks-saved",
method="GET",
path="/bookmarks/saved",
title="View saved bookmarks",
summary="Render your saved content. Returns an HTML page.",
auth="user",
interactive=True,
params=[
field(
"before",
"query",
"string",
False,
"",
"Pagination cursor (created_at of the last item).",
)
],
notes=[
"Bookmarks target posts, projects, gists, and news; see [Posts, Comments, Projects, Gists & News](/docs/content.html)."
],
),
endpoint(
id="polls-vote",
method="POST",
path="/polls/{poll_uid}/vote",
title="Vote in a poll",
summary="Cast, change, or clear your vote on a poll option.",
auth="user",
ajax=True,
encoding="form",
params=[
field(
"poll_uid",
"path",
"string",
True,
"POLL_UID",
"UID of the poll.",
),
field(
"option_uid",
"form",
"string",
True,
"OPTION_UID",
"UID of the chosen option.",
),
],
notes=[
"You hold at most one vote per poll, and only your latest vote counts. "
"Voting a different option replaces your previous choice; voting your current "
"option again removes the vote.",
],
sample_response={
"question": "Best editor?",
"options": [{"uid": "OPTION_UID", "label": "Vim", "votes": 5}],
"total": 5,
"voted": "OPTION_UID",
},
),
],
}

View File

@ -0,0 +1,233 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "tools",
"title": "Tools (SEO & DeepSearch)",
"intro": """
# Tools: SEO Diagnostics & DeepSearch
Two public developer tools that run as background jobs.
**SEO Diagnostics** audits a URL or sitemap with a headless browser and runs a broad battery of
technical, on-page, structured-data, Core Web Vitals, accessibility and AI-readiness checks.
**DeepSearch** is a multi-agent deep web researcher that crawls and indexes sources, then
synthesises a cited report with confidence scoring and gap analysis, plus a grounded chat over
the results.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html). These are
**capability URLs**: the job `uid` is an unguessable identifier, so anyone holding it can read the
status and report.
""",
"endpoints": [
endpoint(
id="tools-seo-run",
method="POST",
path="/tools/seo/run",
title="Queue an SEO audit",
summary="Start a background SEO audit of a URL or sitemap. Returns the job uid plus status and websocket URLs.",
auth="public",
encoding="form",
params=[
field("url", "form", "string", True, "https://example.com", "Page URL or sitemap.xml URL to audit."),
field("mode", "form", "enum", False, "url", "'url' (single page) or 'sitemap' (crawl).", ["url", "sitemap"]),
field("max_pages", "form", "integer", False, "10", "Max pages to crawl in sitemap mode (1-50)."),
],
sample_response={
"uid": "SEO_JOB_UID",
"status_url": "/tools/seo/SEO_JOB_UID",
"ws_url": "/tools/seo/SEO_JOB_UID/ws",
},
),
endpoint(
id="tools-seo-status",
method="GET",
path="/tools/seo/{uid}",
title="SEO audit status",
summary="Poll an SEO audit. Once done, score, grade and report_url are populated.",
auth="public",
params=[
field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid returned when the audit was queued."),
],
sample_response={
"uid": "SEO_JOB_UID",
"kind": "seo",
"status": "done",
"target": "https://example.com",
"mode": "url",
"ws_url": "/tools/seo/SEO_JOB_UID/ws",
"report_url": "/tools/seo/SEO_JOB_UID/report",
"score": 82,
"grade": "B",
"page_count": 1,
"error": None,
"created_at": "2026-06-14T10:00:00+00:00",
"completed_at": "2026-06-14T10:00:18+00:00",
},
),
endpoint(
id="tools-seo-report",
method="GET",
path="/tools/seo/{uid}/report",
title="SEO audit report",
summary="Full categorised report: overall score, per-category subscores, and every check with its recommendation. Negotiates HTML or JSON.",
auth="public",
params=[
field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid of a finished audit."),
],
sample_response={
"uid": "SEO_JOB_UID",
"status": "done",
"target": "https://example.com",
"score": 82,
"grade": "B",
"page_count": 1,
"counts": {"pass": 40, "warn": 8, "fail": 3, "info": 5, "skip": 0},
"categories": {"crawlability": {"score": 90, "pass": 9, "warn": 1, "fail": 0}},
"pages": [{"url": "https://example.com", "status": 200, "score": 82, "grade": "B"}],
"checks": [
{
"id": "meta.title_present",
"category": "meta",
"title": "Title tag",
"status": "pass",
"severity": "high",
"value": "Example Domain",
"recommendation": "",
"url": "https://example.com",
}
],
"site": {"robots": {"status": 200}, "sitemap": {"status": 200, "url_count": 12}},
"generated_at": "2026-06-14T10:00:18+00:00",
},
),
endpoint(
id="tools-seo-screenshot",
method="GET",
path="/tools/seo/{uid}/screenshot/{index}",
title="SEO audit page screenshot",
summary="Stream the rendered screenshot (image/png) captured for the audited page at the given zero-based index.",
auth="public",
interactive=True,
params=[
field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid of a finished audit."),
field("index", "path", "integer", True, "0", "Zero-based index of the audited page."),
],
),
endpoint(
id="tools-seo-meta-status",
method="GET",
path="/tools/seo-meta/{target_type}/{target_uid}",
title="Generated SEO metadata for a content item",
summary="Read the clean, AI-generated SEO title, description and keywords for a published post, project, gist, news article or issue. Returns a plain-content default with status 'pending' until the AI value is ready.",
auth="public",
params=[
field("target_type", "path", "enum", True, "post", "Content type.", ["post", "project", "gist", "news", "issue"]),
field("target_uid", "path", "string", True, "CONTENT_UID", "The content uid (or issue number)."),
],
sample_response={
"uid": "SEO_META_UID",
"target_type": "post",
"target_uid": "CONTENT_UID",
"seo_title": "Building a fast SQLite social network",
"seo_description": "How DevPlace keeps SQLite synchronous and still serves a developer social network fast, with WAL, mmap and batch query helpers.",
"seo_keywords": "sqlite, fastapi, social network, performance, wal",
"status": "ready",
"source": "ai",
"generated_at": "2026-06-14T10:00:18+00:00",
},
),
endpoint(
id="tools-deepsearch-run",
method="POST",
path="/tools/deepsearch/run",
title="Queue a DeepSearch research job",
summary="Start a multi-agent deep web research job. Returns the job uid plus status and websocket URLs. Connect ws_url for live progress frames.",
auth="public",
encoding="form",
params=[
field("query", "form", "string", True, "history of the transistor", "The research question to investigate."),
field("depth", "form", "integer", False, "2", "Research depth (1-4)."),
field("max_pages", "form", "integer", False, "12", "Maximum sources to crawl (1-30)."),
],
sample_response={
"uid": "DEEPSEARCH_JOB_UID",
"status_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID",
"ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws",
"progress_frames_note": (
"The ws_url stream emits newline-delimited JSON frames; the set is append-only "
"and the first frame carries version:1. Each frame has a type: phase "
"(phase, index, total, label), stage, substep (planning angles), queries, "
"candidates, rsearch, progress (done, total, url), page_loaded (source, render, "
"elapsed_ms, done, total), page_cached, page_skipped (reason), page_duplicate, "
"embed_batch (batch, total_batches, backend, done, total), embed_done (backend, "
"chunk_count), agent (agent, status start|done, elapsed_ms, tokens_in, tokens_out), "
"report_ready, done (session_url), failed (message)."
),
},
),
endpoint(
id="tools-deepsearch-status",
method="GET",
path="/tools/deepsearch/{uid}",
title="DeepSearch status",
summary="Poll a DeepSearch job. Once done, score, confidence and session_url are populated.",
auth="public",
params=[
field("uid", "path", "string", True, "DEEPSEARCH_JOB_UID", "DeepSearch job uid returned when the run was queued."),
],
sample_response={
"uid": "DEEPSEARCH_JOB_UID",
"kind": "deepsearch",
"status": "done",
"query": "history of the transistor",
"depth": 2,
"max_pages": 12,
"ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws",
"chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
"session_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/session",
"score": 78,
"confidence": 0.72,
"source_diversity": 0.64,
"page_count": 11,
"chunk_count": 240,
"error": None,
"created_at": "2026-06-14T10:00:00+00:00",
"completed_at": "2026-06-14T10:01:40+00:00",
},
),
endpoint(
id="tools-deepsearch-session",
method="GET",
path="/tools/deepsearch/{uid}/session",
title="DeepSearch report",
summary="Full cited research report: summary, findings, gaps, sources and metrics. Negotiates HTML or JSON.",
auth="public",
params=[
field("uid", "path", "string", True, "DEEPSEARCH_JOB_UID", "DeepSearch job uid of a finished run."),
],
sample_response={
"uid": "DEEPSEARCH_JOB_UID",
"status": "done",
"query": "history of the transistor",
"score": 78,
"confidence": 0.72,
"source_diversity": 0.64,
"page_count": 11,
"chunk_count": 240,
"summary": "The transistor was invented at Bell Labs in 1947...",
"findings": [
{"title": "Invention", "detail": "...", "confidence": 0.8, "citations": [1]}
],
"gaps": ["Limited coverage of later MOSFET developments."],
"sources": [{"url": "https://example.com", "title": "Example", "source": "httpx"}],
"chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
"export_md_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.md",
"export_json_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.json",
"export_pdf_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.pdf",
},
),
],
}

View File

@ -0,0 +1,106 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "uploads",
"title": "Uploads",
"intro": """
# Uploads
Attachment storage. Upload a file - or hand the server a public URL to fetch - to receive an
attachment record, then reference its `uid` in an `attachment_uids` field when creating a post,
comment, project, gist, message, or issue - see
[Posts, Comments, Projects, Gists & News](/docs/content.html). Images and videos embed and
play inline once posted; other types render as download links. The record's `is_image` and
`is_video` flags indicate how the file is displayed.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="uploads-upload",
method="POST",
path="/uploads/upload",
title="Upload a file",
summary="Store a file and return its attachment record.",
auth="user",
encoding="multipart",
params=[field("file", "form", "file", True, "", "The file to upload.")],
notes=[
"Allowed file types and the size limit are configured by administrators. Images and common video formats (mp4, webm, ogv, mov, m4v) are accepted by default.",
"Returns `201` on success, `413` if too large, `415` if the type is not allowed.",
],
sample_response={
"uid": "ATTACHMENT_UID",
"filename": "clip.mp4",
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.mp4",
"size": 20480,
"is_image": False,
"is_video": True,
"mime_type": "video/mp4",
},
),
endpoint(
id="uploads-upload-url",
method="POST",
path="/uploads/upload-url",
title="Attach a file from a URL",
summary="Download a public URL on the server and store it as an attachment.",
auth="user",
params=[
field(
"url",
"form",
"string",
True,
"https://example.com/photo.png",
"Public http(s) URL of the file to download and attach.",
),
field(
"filename",
"form",
"string",
False,
"photo.png",
"Optional filename with an allowed extension, used when the URL has no clear name.",
),
],
notes=[
"The server fetches the URL (SSRF-guarded, size-capped) and stores the bytes through the same pipeline as a direct upload; the response is identical to Upload a file.",
"The file type is taken from the URL path or the response Content-Type. Returns `201` on success, `413` if too large, `415` if the type cannot be resolved to an allowed type, `400` for an unreachable or private address.",
],
sample_response={
"uid": "ATTACHMENT_UID",
"filename": "photo.png",
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
"size": 20480,
"is_image": True,
"is_video": False,
"mime_type": "image/png",
},
),
endpoint(
id="uploads-delete",
method="DELETE",
path="/uploads/delete/{attachment_uid}",
title="Delete an attachment",
summary="Delete an attachment you own; administrators may delete any user's attachment. Soft-deleted (hidden everywhere but restorable; garbage-collected later).",
auth="user",
destructive=True,
params=[
field(
"attachment_uid",
"path",
"string",
True,
"ATTACHMENT_UID",
"UID of the attachment.",
)
],
sample_response={"status": "deleted"},
),
],
}

View File

@ -0,0 +1,123 @@
# retoor <retoor@molodetz.nl>
from devplacepy import schemas
from devplacepy.docs_examples import schema_example, action_example
from ._shared import NON_BODY_ENDPOINTS
def _classify(ep):
if ep["id"] in _PAGE_RESPONSES or ep["id"] in _ACTION_RESPONSES:
return "negotiable"
if ep.get("ajax"):
return "ajax"
if ep["id"] in NON_BODY_ENDPOINTS:
return "none"
return "json"
_PAGE_RESPONSES = {
"auth-signup": schemas.AuthPageOut,
"auth-login": schemas.AuthPageOut,
"auth-forgot-password": schemas.AuthPageOut,
"auth-reset-password": schemas.AuthPageOut,
"home": schemas.LandingOut,
"feed-list": schemas.FeedOut,
"posts-detail": schemas.PostDetailOut,
"projects-list": schemas.ProjectsOut,
"projects-detail": schemas.ProjectDetailOut,
"gists-list": schemas.GistsOut,
"gists-detail": schemas.GistDetailOut,
"news-list": schemas.NewsListOut,
"news-detail": schemas.NewsDetailOut,
"profile-detail": schemas.ProfileOut,
"leaderboard": schemas.LeaderboardOut,
"messages-inbox": schemas.MessagesOut,
"notifications-list": schemas.NotificationsOut,
"issues-list": schemas.IssuesOut,
"issues-detail": schemas.IssueDetailOut,
"issues-attachments-list": schemas.IssueAttachmentsOut,
"bookmarks-saved": schemas.SavedOut,
"admin-users": schemas.AdminUsersOut,
"admin-news-list": schemas.AdminNewsOut,
"admin-settings-get": schemas.AdminSettingsOut,
"admin-audit-log": schemas.AuditLogOut,
"admin-audit-event": schemas.AuditEventOut,
"admin-bots-monitor": schemas.AdminBotsOut,
"containers-admin-edit-page": schemas.AdminContainerEditOut,
}
_ACTION_RESPONSES = {
"auth-signup-post": ("/feed", {"username": "alice"}),
"auth-login-post": ("/feed", None),
"auth-forgot-password-post": ("/auth/forgot-password?sent=1", None),
"auth-reset-password-post": ("/auth/login", None),
"auth-logout": ("/", None),
"posts-create": (
"/posts/POST_SLUG",
{"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"},
),
"posts-edit": (
"/posts/POST_SLUG",
{"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"},
),
"posts-delete": ("/feed", None),
"comments-create": (
"/posts/POST_SLUG#comment-COMMENT_UID",
{"uid": "COMMENT_UID", "url": "/posts/POST_SLUG#comment-COMMENT_UID"},
),
"comments-delete": (
"/posts/POST_SLUG",
{"deleted_uid": "COMMENT_UID", "target_type": "post", "target_uid": "TARGET_UID"},
),
"projects-create": (
"/projects/PROJECT_SLUG",
{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "url": "/projects/PROJECT_SLUG"},
),
"projects-delete": ("/projects", None),
"gists-create": (
"/gists/GIST_SLUG",
{"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"},
),
"gists-edit": (
"/gists/GIST_SLUG",
{"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"},
),
"gists-delete": ("/gists", None),
"profile-update": ("/profile/YOUR_USERNAME", None),
"profile-customization-global": (
"/profile/YOUR_USERNAME",
{"url": "/profile/YOUR_USERNAME", "cust_disable_global": True},
),
"profile-customization-pagetype": (
"/profile/YOUR_USERNAME",
{"url": "/profile/YOUR_USERNAME", "cust_disable_pagetype": True},
),
"follow-user": ("/profile/bob_test", None),
"unfollow-user": ("/profile/bob_test", None),
"messages-send": ("/messages?with_uid=RECEIVER_UID", {"uid": "MESSAGE_UID"}),
"notifications-open": ("/posts/POST_SLUG#comment-COMMENT_UID", None),
"notifications-mark-read": ("/notifications", None),
"notifications-mark-all-read": ("/notifications", None),
"issues-comment": ("/issues/12", {"comment_id": 1}),
"issues-status": ("/issues/12", {"state": "closed"}),
"admin-user-role": ("/admin/users", None),
"admin-user-password": ("/admin/users", None),
"admin-user-toggle": ("/admin/users", None),
"admin-news-toggle": ("/admin/news", None),
"admin-news-publish": ("/admin/news", None),
"admin-news-landing": ("/admin/news", None),
"admin-news-delete": ("/admin/news", None),
"admin-settings": ("/admin/settings", None),
}
def _apply_negotiated_responses(groups):
for group in groups:
for ep in group.get("endpoints", []) or []:
if ep.get("sample_response") is None:
if ep["id"] in _PAGE_RESPONSES:
ep["sample_response"] = schema_example(_PAGE_RESPONSES[ep["id"]])
elif ep["id"] in _ACTION_RESPONSES:
redirect, data = _ACTION_RESPONSES[ep["id"]]
ep["sample_response"] = action_example(redirect, data)
ep["negotiation"] = _classify(ep)

View File

@ -0,0 +1,47 @@
# retoor <retoor@molodetz.nl>
from .groups import ORDERED_GROUPS as API_GROUPS
_GROUPS_BY_SLUG = {group["slug"]: group for group in API_GROUPS}
def get_group(slug):
return _GROUPS_BY_SLUG.get(slug)
def api_doc_pages():
return [
{
"slug": group["slug"],
"title": group["title"],
"kind": "api",
"admin": group.get("admin", False),
"dynamic": group.get("dynamic", False),
}
for group in API_GROUPS
]
def _substitute(value, replacements):
if isinstance(value, str):
for token, replacement in replacements.items():
value = value.replace(token, replacement)
return value
if isinstance(value, list):
return [_substitute(item, replacements) for item in value]
if isinstance(value, dict):
return {key: _substitute(item, replacements) for key, item in value.items()}
return value
def render_group(slug, base, username, api_key):
group = get_group(slug)
if not group:
return None
replacements = {
"{{ base }}": base,
"{{ username }}": username or "YOUR_USERNAME",
"{{ api_key }}": api_key or "YOUR_API_KEY",
}
return _substitute(group, replacements)

View File

@ -0,0 +1,154 @@
# retoor <retoor@molodetz.nl>
from ._shared import SERVICE_ACTIONS, endpoint, field
from .negotiation import _classify
def _service_control_endpoints():
endpoints = []
for action, summary in SERVICE_ACTIONS:
endpoints.append(
endpoint(
id=f"services-{action.replace('-', '')}",
method="POST",
path=f"/admin/services/{{name}}/{action}",
title=f"Service: {action}",
summary=summary + ".",
auth="admin",
interactive=True,
destructive=True,
params=[
field(
"name",
"path",
required=True,
example="news",
description="Registered service name.",
)
],
sample_response={"ok": True},
)
)
return endpoints
def _field_to_param(spec):
description = spec["label"]
if spec.get("help"):
description = f"{spec['label']} - {spec['help']}"
value = "" if spec.get("secret") else str(spec.get("value", ""))
if spec.get("secret"):
description += " Leave blank to keep the current value."
if spec["type"] == "bool":
return field(
spec["key"], "form", "enum", False, value or "0", description, ["1", "0"]
)
if spec["type"] == "select":
options = [option["value"] for option in spec.get("options") or []]
return field(
spec["key"],
"form",
"enum",
False,
value or (options[0] if options else ""),
description,
options,
)
if spec["type"] in ("int", "float"):
return field(spec["key"], "form", "int", False, value, description)
return field(spec["key"], "form", "string", False, value, description)
def _service_section(service):
enabled = "yes" if service.get("enabled") else "no"
groups = ", ".join(group["name"] for group in service.get("field_groups", []))
lines = [
f"## {service.get('title') or service['name']}",
"",
service.get("description", ""),
"",
f"- Service name: `{service['name']}`",
f"- Enabled: {enabled}",
f"- Run interval: {service.get('interval_seconds', 0)}s",
]
if groups:
lines.append(f"- Configuration groups: {groups}")
return "\n".join(lines)
def build_services_group(services, base):
intro_parts = [
"# Background Services",
"",
"DevPlace runs background services supervised by a single manager. Each service has its "
"own enable flag, run interval, live status, metrics, and a rolling log buffer. Manage "
"them from the admin panel at `/admin/services` or with the endpoints below.",
"",
"Control a service by its `name` (shown per service): start or stop it, trigger a single "
"run, clear its logs, or save its configuration. `GET /admin/services/data` returns the "
"live status, metrics, and log tail for every service.",
"",
"See the [Admin API](/docs/admin.html) for the rest of administration; the Devii assistant "
"is itself a service, configured in [Configuration and CLI](/docs/devii-config.html).",
]
config_endpoints = []
for service in services:
intro_parts.append("")
intro_parts.append(_service_section(service))
params = [
_field_to_param(spec)
for spec in service.get("fields", [])
if not spec["key"].endswith("_enabled")
]
config_endpoints.append(
endpoint(
id=f"services-config-{service['name']}",
method="POST",
path=f"/admin/services/{service['name']}/config",
title=f"Configure {service.get('title') or service['name']}",
summary=f"Save configuration for the {service['name']} service. Empty values keep the current setting.",
auth="admin",
encoding="form",
destructive=True,
params=params,
notes=[
"Use start/stop to enable or disable the service; this saves the remaining settings."
],
)
)
control_endpoints = [
endpoint(
id="services-data",
method="GET",
path="/admin/services/data",
title="Service status",
summary="Live status, metrics, and log tail for every background service.",
auth="admin",
sample_response={
"services": [{"name": "news", "status": "running", "enabled": True}]
},
),
endpoint(
id="services-detail-data",
method="GET",
path="/admin/services/{name}/data",
title="One service status",
summary="Live status, metrics, and log tail for a single background service.",
auth="admin",
params=[field("name", "path", "string", True, "news", "Service name.")],
sample_response={
"service": {"name": "news", "status": "running", "enabled": True}
},
),
*_service_control_endpoints(),
]
endpoints = control_endpoints + config_endpoints
for ep in endpoints:
ep["negotiation"] = _classify(ep)
return {
"slug": "services",
"title": "Background Services",
"admin": True,
"intro": "\n".join(intro_parts),
"endpoints": endpoints,
}

View File

@ -26,6 +26,7 @@ from devplacepy.database import (
get_user_media,
search_users_by_username,
mark_notifications_read_by_target,
resolve_object_url,
)
from devplacepy.content import can_view_project, enrich_items
from devplacepy.utils import (
@ -209,6 +210,7 @@ async def profile_page(
"time_ago": time_ago(p["created_at"]),
"created_at": p["created_at"],
"uid": p["uid"],
"url": resolve_object_url("post", p["uid"]),
}
)
comments_table = get_table("comments")
@ -228,6 +230,7 @@ async def profile_page(
"created_at": c["created_at"],
"uid": target_uid,
"target_type": target_type,
"url": f"{resolve_object_url(target_type, target_uid)}#comment-{c['uid']}",
}
)
activities.sort(key=lambda a: a["created_at"], reverse=True)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,147 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from devplacepy.schemas.base import (
ActionResult,
ErrorOut,
ValidationErrorOut,
_Out,
)
from devplacepy.schemas.content import (
AttachmentOut,
BadgeOut,
CommentEditOut,
CommentItemOut,
CommentOut,
GistOut,
MessageOut,
NewsOut,
NotificationOut,
PollOptionOut,
PollOut,
PostOut,
ProjectFileOut,
ProjectFileRawOut,
ProjectFilesOut,
ProjectOut,
ReactionsOut,
UserOut,
VotesOut,
)
from devplacepy.schemas.listings import (
ConversationOut,
FeedItemOut,
FeedOut,
GistDetailOut,
GistItemOut,
GistsOut,
LeaderboardEntryOut,
LeaderboardOut,
MessageItemOut,
MessagesOut,
NewsDetailOut,
NewsListItemOut,
NewsListOut,
NotificationGroupOut,
NotificationItemOut,
NotificationsOut,
PostDetailOut,
ProjectDetailOut,
ProjectListItemOut,
ProjectsOut,
SavedItemOut,
SavedOut,
)
from devplacepy.schemas.profile import (
MediaItemOut,
ProfileOut,
TelegramPairOut,
)
from devplacepy.schemas.issues import (
IssueAttachmentsOut,
IssueCommentOut,
IssueDetailOut,
IssueItemOut,
IssueJobOut,
IssuesOut,
)
from devplacepy.schemas.containers import (
AdminBotsOut,
AdminContainerEditOut,
AdminContainerInstanceOut,
AdminContainersOut,
BotFrameOut,
ContainersOut,
InstanceOut,
ScheduleOut,
)
from devplacepy.schemas.jobs import (
DbQueryJobOut,
DeepsearchJobOut,
DeepsearchSessionOut,
ForkJobOut,
PlanningJobOut,
SeoJobOut,
SeoMetaOut,
SeoReportOut,
ZipJobOut,
)
from devplacepy.schemas.backups import (
BackupDashboardOut,
BackupJobOut,
BackupOut,
BackupScheduleOut,
BackupStoragePathOut,
)
from devplacepy.schemas.admin import (
AdminMediaItemOut,
AdminMediaOut,
AdminNewsItemOut,
AdminNewsOut,
AdminNotificationsOut,
AdminSettingsOut,
AdminTrashOut,
AdminUserOut,
AdminUsersOut,
TrashItemOut,
)
from devplacepy.schemas.gateway import (
GatewayUsageOut,
UserAiUsageOut,
)
from devplacepy.schemas.auth import (
AuthPageOut,
DeviiPageOut,
LandingArticleOut,
LandingOut,
LandingPostOut,
)
from devplacepy.schemas.audit import (
AuditEntryOut,
AuditEventOut,
AuditLinkOut,
AuditLogOut,
)
from devplacepy.schemas.dbapi import (
DbColumnOut,
DbQueryOut,
DbRowOut,
DbRowsOut,
DbSchemaOut,
DbTableListOut,
DbTableOut,
NlQueryOut,
)
from devplacepy.schemas.game import (
GameCropOut,
GameFarmOut,
GameFarmViewOut,
GameLeaderboardEntryOut,
GameLeaderboardOut,
GameLegacyOut,
GamePerkOut,
GamePlotOut,
GameQuestOut,
GameStateOut,
)

View File

@ -0,0 +1,74 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
from devplacepy.schemas.content import NewsOut, UserOut
from devplacepy.schemas.profile import MediaItemOut
class AdminUserOut(UserOut):
role: Optional[str] = None
email: Optional[str] = None
is_active: Optional[bool] = None
posts_count: Optional[int] = None
class AdminNewsItemOut(_Out):
article: NewsOut
time_ago: Optional[str] = None
synced_at: Optional[str] = None
grade: Optional[int] = None
has_image: Optional[bool] = None
class AdminUsersOut(_Out):
users: list[AdminUserOut] = []
pagination: Optional[Any] = None
admin_section: Optional[str] = None
class AdminNewsOut(_Out):
articles: list[AdminNewsItemOut] = []
pagination: Optional[Any] = None
admin_section: Optional[str] = None
class AdminSettingsOut(_Out):
settings: dict = {}
admin_section: Optional[str] = None
class AdminNotificationsOut(_Out):
notification_defaults: list[Any] = []
admin_section: Optional[str] = None
class AdminMediaItemOut(MediaItemOut):
deleted_at: Optional[str] = None
uploader: Optional[str] = None
class AdminMediaOut(_Out):
media: list[AdminMediaItemOut] = []
pagination: Optional[Any] = None
admin_section: Optional[str] = None
class TrashItemOut(_Out):
uid: str
table: str
label: Optional[str] = None
deleted_at: Optional[str] = None
deleted_by: Optional[str] = None
target_url: Optional[str] = None
class AdminTrashOut(_Out):
items: list[TrashItemOut] = []
table: str = "posts"
tables: list[dict] = []
pagination: Optional[Any] = None
admin_section: Optional[str] = None

View File

@ -0,0 +1,56 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
class AuditLinkOut(_Out):
uid: str = ""
relation: str = ""
object_type: str = ""
object_uid: str = ""
object_label: Optional[str] = None
created_at: Optional[str] = None
class AuditEntryOut(_Out):
uid: str = ""
created_at: Optional[str] = None
event_key: str = ""
category: str = ""
actor_kind: str = ""
actor_uid: Optional[str] = None
actor_username: Optional[str] = None
actor_role: str = ""
origin: str = ""
via_agent: int = 0
request_method: Optional[str] = None
request_path: Optional[str] = None
actor_ip: Optional[str] = None
user_agent: Optional[str] = None
target_type: Optional[str] = None
target_uid: Optional[str] = None
target_label: Optional[str] = None
old_value: Optional[str] = None
new_value: Optional[str] = None
summary: Optional[str] = None
metadata: Optional[Any] = None
result: str = "success"
class AuditLogOut(_Out):
entries: list[AuditEntryOut] = []
pagination: Optional[dict] = None
pagination_query: Optional[str] = None
filters: dict = {}
options: dict = {}
admin_section: Optional[str] = None
class AuditEventOut(_Out):
event: Optional[AuditEntryOut] = None
links: list[AuditLinkOut] = []
admin_section: Optional[str] = None

View File

@ -0,0 +1,64 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
from devplacepy.schemas.content import UserOut
class AuthPageOut(_Out):
page: str = ""
next_url: Optional[str] = None
registration_closed: Optional[bool] = None
sent: Optional[bool] = None
token: Optional[str] = None
errors: list = []
class LandingArticleOut(_Out):
uid: str = ""
slug: str = ""
title: Optional[str] = None
description: Optional[str] = None
url: Optional[str] = None
source_name: Optional[str] = None
grade: int = 0
featured: int = 0
synced_at: Optional[str] = None
time_ago: Optional[str] = None
image_url: Optional[str] = None
class LandingPostOut(_Out):
post: Optional[Any] = None
author: Optional[UserOut] = None
time_ago: Optional[str] = None
comment_count: int = 0
stars: int = 0
slug: str = ""
class LandingOut(_Out):
is_authenticated: bool = False
user_post_count: int = 0
user_stars: int = 0
landing_articles: list[LandingArticleOut] = []
landing_posts: list[LandingPostOut] = []
class DeviiPageOut(_Out):
page_title: Optional[str] = None
meta_description: Optional[str] = None
meta_robots: Optional[str] = None
canonical_url: Optional[str] = None
og_title: Optional[str] = None
og_description: Optional[str] = None
og_image: Optional[str] = None
og_type: Optional[str] = None
breadcrumbs: list = []
page_schema: Optional[Any] = None
prev_url: Optional[str] = None
next_url: Optional[str] = None
user: Optional[Any] = None

View File

@ -0,0 +1,80 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Optional
from devplacepy.schemas.base import _Out
class BackupStoragePathOut(_Out):
key: str = ""
label: str = ""
path: str = ""
size_bytes: int = 0
size_human: str = ""
file_count: int = 0
exists: bool = False
class BackupOut(_Out):
uid: str = ""
job_uid: str = ""
target: str = ""
label: str = ""
status: str = ""
filename: str = ""
size_bytes: int = 0
size_human: str = ""
bytes_in: int = 0
file_count: int = 0
dir_count: int = 0
sha256: str = ""
schedule_uid: str = ""
created_by: str = ""
created_at: Optional[str] = None
completed_at: Optional[str] = None
error: Optional[str] = None
download_url: Optional[str] = None
class BackupJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
target: Optional[str] = None
backup_uid: Optional[str] = None
download_url: Optional[str] = None
error: Optional[str] = None
bytes_out: int = 0
file_count: int = 0
sha256: Optional[str] = None
created_at: Optional[str] = None
completed_at: Optional[str] = None
class BackupScheduleOut(_Out):
uid: str = ""
name: str = ""
target: str = ""
kind: str = ""
every_seconds: int = 0
cron: Optional[str] = None
enabled: int = 0
keep_last: int = 0
next_run_at: Optional[str] = None
last_run_at: Optional[str] = None
last_job_uid: Optional[str] = None
run_count: int = 0
created_at: Optional[str] = None
class BackupDashboardOut(_Out):
storage: dict = {}
backups: list[BackupOut] = []
schedules: list[BackupScheduleOut] = []
targets: list[dict] = []
metrics: dict = {}
generated_at: Optional[str] = None
can_download_backups: bool = False
admin_section: Optional[str] = None

View File

@ -0,0 +1,27 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict
class _Out(BaseModel):
model_config = ConfigDict(extra="ignore")
class ActionResult(_Out):
ok: bool = True
redirect: Optional[str] = None
data: Optional[Any] = None
class ErrorOut(_Out):
error: dict
class ValidationErrorOut(_Out):
error: str = "validation"
fields: dict = {}
messages: list = []

View File

@ -0,0 +1,100 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
class InstanceOut(_Out):
uid: str = ""
project_uid: str = ""
name: str = ""
slug: str = ""
container_id: Optional[str] = None
status: Optional[str] = None
desired_state: Optional[str] = None
restart_policy: Optional[str] = None
restart_count: int = 0
ingress_slug: Optional[str] = None
ingress_port: int = 0
boot_command: Optional[str] = None
boot_language: Optional[str] = None
boot_script: Optional[str] = None
run_as_uid: Optional[str] = None
start_on_boot: int = 0
cpu_limit: Optional[str] = None
mem_limit: Optional[str] = None
created_at: Optional[str] = None
started_at: Optional[str] = None
stopped_at: Optional[str] = None
class ScheduleOut(_Out):
uid: str = ""
instance_uid: str = ""
action: Optional[str] = None
enabled: int = 0
next_run_at: Optional[str] = None
last_run_at: Optional[str] = None
run_count: int = 0
class ContainersOut(_Out):
project: Optional[dict] = None
instances: list[InstanceOut] = []
class AdminContainersOut(_Out):
instances: list = []
projects: list = []
boot_languages: list = []
restart_policies: list = []
admin_section: Optional[str] = None
user: Optional[Any] = None
class AdminContainerEditOut(_Out):
instance: Optional[Any] = None
project: Optional[dict] = None
run_as_user: Optional[dict] = None
boot_languages: list = []
restart_policies: list = []
admin_section: Optional[str] = None
user: Optional[Any] = None
class AdminContainerInstanceOut(_Out):
instance: Optional[Any] = None
project: Optional[dict] = None
project_slug: Optional[str] = None
events: list = []
schedules: list = []
stats: Optional[Any] = None
runtime: Optional[Any] = None
admin_section: Optional[str] = None
user: Optional[Any] = None
class BotFrameOut(_Out):
slot: int = 0
username: str = ""
persona: str = ""
action: str = ""
status: str = ""
url: str = ""
label: str = ""
captured_at: int = 0
age_seconds: int = 0
has_image: bool = False
active: bool = False
frame_url: str = ""
class AdminBotsOut(_Out):
frames: list[BotFrameOut] = []
enabled: bool = False
service_status: str = ""
admin_section: Optional[str] = None
user: Optional[Any] = None

View File

@ -0,0 +1,203 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
class UserOut(_Out):
uid: str = ""
username: str = ""
avatar_seed: Optional[str] = None
bio: Optional[str] = None
location: Optional[str] = None
git_link: Optional[str] = None
website: Optional[str] = None
level: Optional[int] = None
xp: Optional[int] = None
stars: Optional[int] = None
created_at: Optional[str] = None
class AttachmentOut(_Out):
uid: str = ""
filename: Optional[str] = None
url: Optional[str] = None
size: Optional[int] = None
is_image: Optional[bool] = None
is_video: Optional[bool] = None
mime_type: Optional[str] = None
created_at: Optional[str] = None
can_modify: bool = False
class VotesOut(_Out):
up: int = 0
down: int = 0
class ReactionsOut(_Out):
counts: dict = {}
mine: list = []
class PollOptionOut(_Out):
uid: str = ""
label: Optional[str] = None
count: Optional[int] = None
votes: Optional[int] = None
pct: Optional[int] = None
class PollOut(_Out):
uid: str = ""
question: Optional[str] = None
options: list[PollOptionOut] = []
total: int = 0
my_choice: Optional[str] = None
voted: Optional[str] = None
class BadgeOut(_Out):
name: Optional[str] = None
created_at: Optional[str] = None
class PostOut(_Out):
uid: str = ""
slug: Optional[str] = None
user_uid: Optional[str] = None
title: Optional[str] = None
content: Optional[str] = None
topic: Optional[str] = None
stars: Optional[int] = None
image: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
class GistOut(_Out):
uid: str = ""
slug: Optional[str] = None
user_uid: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
source_code: Optional[str] = None
language: Optional[str] = None
stars: Optional[int] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
class ProjectOut(_Out):
uid: str = ""
slug: Optional[str] = None
user_uid: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
project_type: Optional[str] = None
status: Optional[str] = None
platforms: Optional[Any] = None
stars: Optional[int] = None
is_private: Optional[bool] = None
read_only: Optional[bool] = None
release_date: Optional[str] = None
demo_date: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
class ProjectFileOut(_Out):
uid: str = ""
path: str = ""
name: Optional[str] = None
type: Optional[str] = None
is_binary: Optional[bool] = None
mime_type: Optional[str] = None
size: Optional[int] = None
url: Optional[str] = None
updated_at: Optional[str] = None
class ProjectFileRawOut(ProjectFileOut):
content: Optional[str] = None
class ProjectFilesOut(_Out):
project: ProjectOut
files: list[ProjectFileOut] = []
is_owner: bool = False
read_only: bool = False
is_private: bool = False
class NewsOut(_Out):
uid: str = ""
slug: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
content: Optional[str] = None
url: Optional[str] = None
source_name: Optional[str] = None
author: Optional[str] = None
grade: Optional[int] = None
ai_grade: Optional[int] = None
status: Optional[str] = None
image_url: Optional[str] = None
featured: Optional[int] = None
has_unique_image: Optional[int] = None
article_published: Optional[str] = None
created_at: Optional[str] = None
synced_at: Optional[str] = None
class CommentOut(_Out):
uid: str = ""
user_uid: Optional[str] = None
content: Optional[str] = None
parent_uid: Optional[str] = None
target_type: Optional[str] = None
target_uid: Optional[str] = None
created_at: Optional[str] = None
class CommentEditOut(_Out):
uid: str = ""
content: str = ""
url: str = ""
updated_at: Optional[str] = None
class CommentItemOut(_Out):
comment: CommentOut
author: Optional[UserOut] = None
time_ago: Optional[str] = None
votes: VotesOut = VotesOut()
my_vote: int = 0
children: list["CommentItemOut"] = []
attachments: list[AttachmentOut] = []
reactions: ReactionsOut = ReactionsOut()
class NotificationOut(_Out):
uid: str = ""
type: Optional[str] = None
message: Optional[str] = None
read: Optional[bool] = None
related_uid: Optional[str] = None
target_url: Optional[str] = None
created_at: Optional[str] = None
class MessageOut(_Out):
uid: str = ""
sender_uid: Optional[str] = None
receiver_uid: Optional[str] = None
content: Optional[str] = None
read: Optional[bool] = None
created_at: Optional[str] = None
CommentItemOut.model_rebuild()

View File

@ -0,0 +1,70 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
class DbTableOut(_Out):
name: str = ""
row_count: int = 0
soft_delete: bool = False
class DbTableListOut(_Out):
tables: list[DbTableOut] = []
count: int = 0
class DbColumnOut(_Out):
name: str = ""
type: str = ""
class DbSchemaOut(_Out):
table: str = ""
columns: list[DbColumnOut] = []
soft_delete: bool = False
row_count: int = 0
class DbRowsOut(_Out):
table: str = ""
rows: list[dict] = []
count: int = 0
next_cursor: Optional[Any] = None
class DbRowOut(_Out):
table: str = ""
row: Optional[dict] = None
class DbQueryOut(_Out):
sql: str = ""
valid: bool = False
statement_type: str = ""
rows: list[dict] = []
row_count: int = 0
truncated: bool = False
suspicious: list[str] = []
error: Optional[str] = None
class NlQueryOut(_Out):
question: str = ""
table: str = ""
sql: str = ""
valid: bool = False
attempts: int = 0
dialect: str = "sqlite"
applied_soft_delete: bool = False
suspicious: list[str] = []
tables: list[str] = []
executed: bool = False
rows: list[dict] = []
row_count: int = 0
truncated: bool = False
error: Optional[str] = None

136
devplacepy/schemas/game.py Normal file
View File

@ -0,0 +1,136 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from devplacepy.schemas.base import _Out
class GameCropOut(_Out):
key: str = ""
name: str = ""
icon: str = ""
cost: int = 0
reward_coins: int = 0
reward_xp: int = 0
min_level: int = 1
grow_seconds: int = 0
locked: bool = False
class GamePlotOut(_Out):
slot: int = 0
state: str = "empty"
crop_key: str = ""
crop_name: str = ""
crop_icon: str = ""
reward_coins: int = 0
reward_xp: int = 0
ready_at: str = ""
remaining_seconds: int = 0
watered_count: int = 0
max_waters: int = 0
can_water: bool = False
can_steal: bool = False
steal_coins: int = 0
steal_cooldown_seconds: int = 0
steal_reason: str = ""
is_golden: bool = False
fertilize_cost: int = 0
class GamePerkOut(_Out):
key: str = ""
name: str = ""
icon: str = ""
description: str = ""
level: int = 0
max_level: int = 0
cost: int = 0
maxed: bool = False
effect: str = ""
class GameLegacyOut(_Out):
key: str = ""
name: str = ""
icon: str = ""
description: str = ""
level: int = 0
max_level: int = 0
cost: int = 0
maxed: bool = False
effect: str = ""
class GameQuestOut(_Out):
kind: str = ""
label: str = ""
goal: int = 0
progress: int = 0
reward_coins: int = 0
reward_xp: int = 0
claimed: bool = False
can_claim: bool = False
class GameFarmOut(_Out):
owner_username: str = ""
owner_uid: str = ""
is_owner: bool = False
coins: int = 0
xp: int = 0
level: int = 1
level_into: int = 0
level_span: int = 0
level_is_max: bool = False
total_harvests: int = 0
plot_count: int = 0
max_plots: int = 0
next_plot_cost: int = 0
ci_tier: int = 1
ci_label: str = ""
ci_speed: float = 1.0
ci_next_tier: int = 0
ci_next_label: str = ""
ci_next_cost: int = 0
plots: list[GamePlotOut] = []
crops: list[GameCropOut] = []
prestige: int = 0
prestige_multiplier: float = 1.0
prestige_min_level: int = 0
prestige_available: bool = False
streak: int = 0
daily_available: bool = False
daily_reward: int = 0
perks: list[GamePerkOut] = []
quests: list[GameQuestOut] = []
stars: int = 0
legacy: list[GameLegacyOut] = []
steal_cooldown_seconds: int = 0
class GameStateOut(_Out):
ok: bool = True
farm: GameFarmOut
class GameFarmViewOut(_Out):
farm: GameFarmOut
page_title: str = ""
meta_description: str = ""
stole_coins: int = 0
class GameLeaderboardEntryOut(_Out):
rank: int = 0
username: str = ""
level: int = 1
xp: int = 0
coins: int = 0
total_harvests: int = 0
prestige: int = 0
score: int = 0
class GameLeaderboardOut(_Out):
entries: list[GameLeaderboardEntryOut] = []

View File

@ -0,0 +1,42 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Optional
from devplacepy.schemas.base import _Out
class GatewayUsageOut(_Out):
window_hours: int = 48
generated_at: Optional[str] = None
requests: int = 0
volume: dict = {}
tokens: dict = {}
latency: dict = {}
errors: dict = {}
cost: dict = {}
behavior: dict = {}
hourly: list = []
notes: dict = {}
admin_section: Optional[str] = None
class UserAiUsageOut(_Out):
owner_id: str = ""
window_hours: int = 24
generated_at: Optional[str] = None
requests: int = 0
success: int = 0
failed: int = 0
success_pct: float = 0.0
error_pct: float = 0.0
tokens: dict = {}
cost: dict = {}
latency: dict = {}
first_used: Optional[str] = None
last_used: Optional[str] = None
by_model: list = []
by_backend: list = []
hourly: list = []
notes: dict = {}

View File

@ -0,0 +1,69 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
from devplacepy.schemas.content import AttachmentOut
class IssueItemOut(_Out):
number: int = 0
title: str = ""
state: str = "open"
html_url: Optional[str] = None
comments_count: int = 0
created_at: Optional[str] = None
updated_at: Optional[str] = None
author_username: Optional[str] = None
author_uid: Optional[str] = None
author_avatar_seed: Optional[str] = None
is_local_author: bool = False
class IssueCommentOut(_Out):
id: int = 0
body: str = ""
html_url: Optional[str] = None
created_at: Optional[str] = None
author_username: Optional[str] = None
author_uid: Optional[str] = None
author_avatar_seed: Optional[str] = None
is_local_author: bool = False
attachments: list[AttachmentOut] = []
class IssueDetailOut(_Out):
issue: IssueItemOut
body: str = ""
comments: list[IssueCommentOut] = []
attachments: list[AttachmentOut] = []
can_comment: bool = False
can_attach: bool = False
viewer_is_admin: bool = False
class IssueAttachmentsOut(_Out):
number: int = 0
attachments: list[AttachmentOut] = []
class IssueJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
number: Optional[int] = None
issue_url: Optional[str] = None
enhanced: bool = False
error: Optional[str] = None
created_at: Optional[str] = None
completed_at: Optional[str] = None
class IssuesOut(_Out):
issues: list[IssueItemOut] = []
pagination: Optional[Any] = None
state: str = "open"
configured: bool = True
error_message: Optional[str] = None

155
devplacepy/schemas/jobs.py Normal file
View File

@ -0,0 +1,155 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Optional
from devplacepy.schemas.base import _Out
class ZipJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
preferred_name: Optional[str] = None
download_url: Optional[str] = None
error: Optional[str] = None
bytes_in: int = 0
bytes_out: int = 0
item_count: int = 0
file_count: int = 0
dir_count: int = 0
created_at: Optional[str] = None
completed_at: Optional[str] = None
class PlanningJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
preferred_name: Optional[str] = None
download_url: Optional[str] = None
markdown: Optional[str] = None
ai_used: bool = False
error: Optional[str] = None
issue_count: int = 0
bytes_out: int = 0
created_at: Optional[str] = None
completed_at: Optional[str] = None
class ForkJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
preferred_name: Optional[str] = None
project_uid: Optional[str] = None
project_url: Optional[str] = None
source_project_uid: Optional[str] = None
error: Optional[str] = None
item_count: int = 0
created_at: Optional[str] = None
completed_at: Optional[str] = None
class SeoJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
target: Optional[str] = None
mode: Optional[str] = None
ws_url: Optional[str] = None
report_url: Optional[str] = None
score: Optional[int] = None
grade: Optional[str] = None
page_count: int = 0
error: Optional[str] = None
created_at: Optional[str] = None
completed_at: Optional[str] = None
class SeoReportOut(_Out):
uid: str = ""
status: str = ""
target: Optional[str] = None
mode: Optional[str] = None
score: Optional[int] = None
grade: Optional[str] = None
page_count: int = 0
counts: dict = {}
categories: dict = {}
pages: list = []
checks: list = []
site: dict = {}
generated_at: Optional[str] = None
class SeoMetaOut(_Out):
uid: str = ""
target_type: str = ""
target_uid: str = ""
seo_title: str = ""
seo_description: str = ""
seo_keywords: str = ""
status: str = ""
source: str = ""
generated_at: Optional[str] = None
class DeepsearchJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
query: Optional[str] = None
depth: int = 0
max_pages: int = 0
ws_url: Optional[str] = None
chat_ws_url: Optional[str] = None
session_url: Optional[str] = None
score: Optional[int] = None
confidence: Optional[float] = None
source_diversity: Optional[float] = None
page_count: int = 0
chunk_count: int = 0
error: Optional[str] = None
created_at: Optional[str] = None
completed_at: Optional[str] = None
class DeepsearchSessionOut(_Out):
uid: str = ""
status: str = ""
query: Optional[str] = None
depth: int = 0
max_pages: int = 0
score: Optional[int] = None
confidence: Optional[float] = None
source_diversity: Optional[float] = None
page_count: int = 0
chunk_count: int = 0
summary: Optional[str] = None
sources: list = []
findings: list = []
gaps: list = []
timeline: list = []
chat_ws_url: Optional[str] = None
export_md_url: Optional[str] = None
export_json_url: Optional[str] = None
export_pdf_url: Optional[str] = None
viewer_is_admin: bool = False
viewer_owns: bool = False
created_at: Optional[str] = None
completed_at: Optional[str] = None
class DbQueryJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
ws_url: Optional[str] = None
result_url: Optional[str] = None
row_count: Optional[int] = None
truncated: Optional[bool] = None
error: Optional[str] = None
created_at: Optional[str] = None
completed_at: Optional[str] = None

View File

@ -0,0 +1,231 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
from devplacepy.schemas.content import (
AttachmentOut,
CommentItemOut,
GistOut,
MessageOut,
NewsOut,
NotificationOut,
PollOut,
PostOut,
ProjectOut,
ReactionsOut,
UserOut,
)
class FeedItemOut(_Out):
post: PostOut
author: Optional[UserOut] = None
time_ago: Optional[str] = None
my_vote: int = 0
comment_count: int = 0
attachments: list[AttachmentOut] = []
recent_comments: list[CommentItemOut] = []
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
poll: Optional[PollOut] = None
class GistItemOut(_Out):
gist: GistOut
author: Optional[UserOut] = None
time_ago: Optional[str] = None
my_vote: int = 0
comment_count: int = 0
recent_comments: list[CommentItemOut] = []
class ProjectListItemOut(ProjectOut):
author_name: Optional[str] = None
my_vote: int = 0
recent_comments: list[CommentItemOut] = []
class NewsListItemOut(_Out):
article: NewsOut
time_ago: Optional[str] = None
image_url: Optional[str] = None
grade: Optional[int] = None
featured: Optional[int] = None
recent_comments: list[CommentItemOut] = []
class ConversationOut(_Out):
other_user: Optional[UserOut] = None
last_message: Optional[str] = None
last_message_at: Optional[str] = None
unread: bool = False
class MessageItemOut(_Out):
message: MessageOut
sender: Optional[UserOut] = None
is_mine: bool = False
time_ago: Optional[str] = None
attachments: list[AttachmentOut] = []
class NotificationItemOut(_Out):
notification: NotificationOut
actor: Optional[UserOut] = None
time_ago: Optional[str] = None
class NotificationGroupOut(_Out):
label: Optional[str] = None
entries: list[NotificationItemOut] = []
class LeaderboardEntryOut(_Out):
uid: Optional[str] = None
username: Optional[str] = None
avatar_seed: Optional[str] = None
stars: Optional[int] = None
level: Optional[int] = None
rank: Optional[int] = None
class SavedItemOut(_Out):
target_type: Optional[str] = None
target_uid: Optional[str] = None
type_label: Optional[str] = None
title: Optional[str] = None
url: Optional[str] = None
time_ago: Optional[str] = None
class FeedOut(_Out):
posts: list[FeedItemOut] = []
current_tab: Optional[str] = None
current_topic: Optional[str] = None
search: Optional[str] = None
next_cursor: Optional[str] = None
total_members: Optional[int] = None
posts_today: Optional[int] = None
total_projects: Optional[int] = None
total_gists: Optional[int] = None
top_authors: list[UserOut] = []
daily_topic: Optional[Any] = None
class PostDetailOut(_Out):
post: PostOut
author: Optional[UserOut] = None
is_owner: bool = False
star_count: int = 0
my_vote: int = 0
time_ago: Optional[str] = None
comments: list[CommentItemOut] = []
attachments: list[AttachmentOut] = []
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
poll: Optional[PollOut] = None
comment_count: Optional[int] = None
related_posts: list[FeedItemOut] = []
topics: list[str] = []
class ProjectsOut(_Out):
projects: list[ProjectListItemOut] = []
current_tab: Optional[str] = None
search: Optional[str] = None
project_type: Optional[str] = None
total_count: Optional[int] = None
next_cursor: Optional[str] = None
total_members: Optional[int] = None
top_authors: list[UserOut] = []
class ProjectDetailOut(_Out):
project: ProjectOut
author: Optional[UserOut] = None
is_owner: bool = False
star_count: int = 0
my_vote: int = 0
time_ago: Optional[str] = None
comments: list[CommentItemOut] = []
attachments: list[AttachmentOut] = []
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
platforms: Optional[Any] = None
is_private: bool = False
read_only: bool = False
forked_from: Optional[dict] = None
fork_count: int = 0
file_count: int = 0
class GistsOut(_Out):
gists: list[GistItemOut] = []
total_count: Optional[int] = None
next_cursor: Optional[str] = None
current_language: Optional[str] = None
search: Optional[str] = None
languages: list[tuple[str, str]] = []
gist_language_codes: list[str] = []
class GistDetailOut(_Out):
gist: GistOut
author: Optional[UserOut] = None
is_owner: bool = False
star_count: int = 0
my_vote: int = 0
time_ago: Optional[str] = None
comments: list[CommentItemOut] = []
attachments: list[AttachmentOut] = []
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
class NewsListOut(_Out):
articles: list[NewsListItemOut] = []
next_cursor: Optional[str] = None
class NewsDetailOut(_Out):
article: NewsOut
canonical_slug: Optional[str] = None
image_url: Optional[str] = None
grade: Optional[int] = None
time_ago: Optional[str] = None
comments: list[CommentItemOut] = []
bookmarked: bool = False
class MessagesOut(_Out):
conversations: list[ConversationOut] = []
messages: list[MessageItemOut] = []
other_user: Optional[UserOut] = None
current_conversation: Optional[str] = None
search: Optional[str] = None
other_online: bool = False
other_last_seen: Optional[str] = None
class NotificationsOut(_Out):
notification_groups: list[NotificationGroupOut] = []
next_cursor: Optional[str] = None
class LeaderboardOut(_Out):
entries: list[LeaderboardEntryOut] = []
user_rank: Optional[int] = None
total_members: Optional[int] = None
posts_today: Optional[int] = None
total_projects: Optional[int] = None
total_gists: Optional[int] = None
top_authors: list[UserOut] = []
featured_news: list[NewsOut] = []
class SavedOut(_Out):
items: list[SavedItemOut] = []
next_cursor: Optional[str] = None

View File

@ -0,0 +1,79 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
from devplacepy.schemas.content import BadgeOut, ProjectOut, UserOut
from devplacepy.schemas.listings import FeedItemOut, GistItemOut
class MediaItemOut(_Out):
uid: str = ""
original_filename: Optional[str] = None
file_size: Optional[int] = None
mime_type: Optional[str] = None
url: Optional[str] = None
thumbnail_url: Optional[str] = None
has_thumbnail: bool = False
is_image: bool = False
is_video: bool = False
target_type: Optional[str] = None
target_uid: Optional[str] = None
target_url: Optional[str] = None
created_at: Optional[str] = None
class ProfileOut(_Out):
profile_user: UserOut
posts: list[FeedItemOut] = []
badges: list[BadgeOut] = []
achievements: list[dict] = []
badge_total: int = 0
badge_earned: int = 0
projects: list[ProjectOut] = []
gists: list[GistItemOut] = []
current_tab: Optional[str] = None
posts_count: Optional[int] = None
is_following: bool = False
is_blocked: bool = False
is_muted: bool = False
is_owner: bool = False
can_view_api_key: bool = False
api_key: Optional[str] = None
ai_correction_enabled: bool = False
ai_correction_sync: bool = False
ai_correction_prompt: Optional[str] = None
ai_modifier_enabled: bool = False
ai_modifier_sync: bool = False
ai_modifier_prompt: Optional[str] = None
telegram_paired: bool = False
notif_telegram_paired: bool = False
can_manage_customization: bool = False
cust_disable_global: bool = False
cust_disable_pagetype: bool = False
ai_quota: Optional[dict] = None
correction_usage: Optional[dict] = None
modifier_usage: Optional[dict] = None
activities: list[Any] = []
rank: Optional[int] = None
heatmap: Optional[Any] = None
heatmap_months: Optional[Any] = None
streak: Optional[Any] = None
people: list[Any] = []
follow_pagination: Optional[Any] = None
followers_count: Optional[int] = None
following_count: Optional[int] = None
viewer_is_admin: bool = False
media: list[MediaItemOut] = []
media_pagination: Optional[Any] = None
notification_prefs: list[Any] = []
class TelegramPairOut(_Out):
ok: bool = True
paired: bool = False
code: Optional[str] = None
expires_at: Optional[str] = None
ttl_minutes: Optional[int] = None

View File

@ -0,0 +1,238 @@
# retoor <retoor@molodetz.nl>
import logging
import random
logger = logging.getLogger(__name__)
class BotAgentMixin:
def _build_menu(self, page_state: dict) -> list[dict]:
page = page_state["page"]
menu: list[dict] = []
def add(action: str, desc: str) -> None:
menu.append({"action": action, "desc": desc})
if page == "post":
if not page_state["own_post"] or page_state["mentioned"]:
add("comment", "write a comment on this post")
add("reply", "reply to an existing comment in the thread")
add("vote", "upvote this post")
add("vote_comment", "upvote a good comment in the thread")
add("vote_poll", "answer the attached poll if there is one")
add("react", "add an emoji reaction to this post")
elif page == "news_detail":
add("comment", "write a comment reacting to this news article")
elif page == "project_detail":
add("vote", "star this project")
add("react", "add an emoji reaction")
add("comment", "write a comment on this project")
elif page == "gist_detail":
add("comment", "write a comment on this code snippet")
add("vote", "star this gist")
add("react", "add an emoji reaction")
elif page == "profile":
if page_state.get("own_profile"):
if not self.state.profile_filled:
add("update_profile", "fill in your own profile bio and links")
add("open_post", "open one of your posts to read it")
else:
if not page_state["follows"]:
add("follow", "follow this developer")
if page_state["can_message"]:
add("message", "send this developer a direct message")
add("open_post", "open one of their posts to read it")
elif page == "projects_list":
add("vote", "star a project in the list")
if page_state["can_project"]:
add("create_project", "create a new project of your own")
add("open_project", "open a project to read it")
elif page == "gists_list":
add("vote", "star a gist in the list")
if not page_state["gists"]:
add("create_gist", "publish a new code snippet of your own")
add("open_gist", "open a gist to read it")
elif page == "news_list":
add("open_news", "open a news article to read it")
elif page == "notifications":
add("check_notifications", "read and act on your notifications")
else:
add("open_post", "open a post from the feed to read it")
add("vote", "upvote a post in the feed")
add("react", "react to a post in the feed")
add("vote_poll", "answer a poll in the feed")
if page_state["can_post"] and not page_state["did_post"]:
add("create_post", "write a new post reacting to a tech news article")
if not page_state["gists"]:
add("create_gist", "publish a new code snippet of your own")
add("open_profile", "open another developer's profile")
add("search", "search the platform for a topic you care about")
add("browse", "browse a category or section")
if page != "notifications" and page_state.get("unread_notifications"):
add(
"check_notifications",
"open your notifications and reply to anyone who mentioned or replied to you",
)
add("navigate", "move to another section: feed, projects, gists, or news")
return menu
def _record_history(self, action: str, rationale: str) -> None:
note = action.replace("_", " ")
if rationale:
note = f"{note} ({rationale[:60]})"
self._session_history.append(note)
if len(self._session_history) > 20:
self._session_history = self._session_history[-20:]
async def _vote_for_page(self, page: str) -> bool:
if page in ("project_detail", "projects_list"):
return await self._vote_on_project()
if page in ("gist_detail", "gists_list"):
return await self._vote_on_gist()
return await self._vote_on_feed()
async def _navigate_to(self, target: str) -> bool:
routes = {
"feed": "/feed",
"projects": "/projects",
"gists": "/gists",
"news": "/news",
"notifications": "/notifications",
}
path = routes.get((target or "").lower())
if path:
await self.b.goto(f"{self.base_url}{path}")
return True
return await self._random_nav_click()
async def _dispatch_action(self, action: str, target: str, page_state: dict) -> bool:
if action == "navigate":
return await self._navigate_to(target)
if action == "vote":
return await self._vote_for_page(page_state["page"])
handlers = {
"comment": self._comment_on_post,
"reply": self._reply_to_random_comment,
"vote_comment": self._vote_on_comments,
"vote_poll": self._vote_on_poll,
"react": self._react_to_object,
"create_post": self._create_post,
"create_gist": self._create_gist,
"create_project": self._click_create_project,
"update_profile": self._update_profile,
"follow": self._follow_user,
"message": self._send_message,
"open_post": self._click_post_link,
"open_gist": self._click_gist_link,
"open_profile": self._click_profile_link,
"open_project": lambda: self._open_content("project"),
"open_news": lambda: self._open_content("news"),
"check_notifications": self._check_notifications,
"search": lambda: self._search("feed"),
"browse": self._browse_section,
}
handler = handlers.get(action)
if handler is None:
return False
try:
result = await handler()
except Exception as e:
logger.debug("dispatch %s failed: %s", action, e)
return False
return True if result is None else bool(result)
async def _run_plan(
self, plan: list[dict], page_state: dict
) -> tuple[int, bool, int, int, int]:
b = self.b
actions = 0
did_post = False
projs = 0
follows = 0
gists = 0
if not plan:
await self._navigate_to("feed")
return 0, did_post, projs, follows, gists
for entry in plan:
action = entry["action"]
if action == "create_post" and (did_post or page_state["did_post"]):
continue
if action == "create_gist" and (gists or page_state["gists"]):
continue
if action == "create_project" and projs:
continue
if await self._dispatch_action(action, entry.get("target", ""), page_state):
actions += 1
self._record_history(action, entry.get("rationale", ""))
if action == "create_post":
did_post = True
elif action == "create_project":
projs += 1
elif action == "follow":
follows += 1
elif action == "create_gist":
gists += 1
await b._idle(0.5, 1.5)
return actions, did_post, projs, follows, gists
async def _cycle_ai(
self,
mood: str = "normal",
*,
session_posted: bool = False,
session_projects: int = 0,
session_follows: int = 0,
session_gists: int = 0,
can_project: bool = False,
can_post: bool = False,
can_message: bool = False,
) -> tuple[int, bool, int, int, int]:
b = self.b
curl = await b.url()
page_text = (await b.html()).lower()
page_state = await self._page_state(
curl,
page_text,
did_post=session_posted,
follows=session_follows,
gists=session_gists,
can_project=can_project,
can_post=can_post,
can_message=can_message,
)
if page_state["page"] in (
"post",
"news_detail",
"project_detail",
"gist_detail",
):
await self._read_like_human(4, 90)
menu = self._build_menu(page_state)
decision = await self._generate(
self.llm.decide,
self.state.identity,
page_state,
menu,
self._session_history,
self._decision_temperature,
)
if not decision:
decision = {"plan": [], "energy": self._session_energy, "stop_after": 0}
self._session_energy = decision.get("energy", self._session_energy)
self._session_stop_after = decision.get("stop_after", 0)
actions, did_post, projs, follows, gists = await self._run_plan(
decision["plan"], page_state
)
return (
actions,
did_post or session_posted,
session_projects + projs,
session_follows + follows,
session_gists + gists,
)
def _scaled_pause(self) -> int:
base = random.randint(self._pause_min, self._pause_max)
factor = 1.4 - 0.8 * self._session_energy
return max(1, int(base * factor))

View File

@ -0,0 +1,123 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import random
from datetime import datetime
from devplacepy.services.bot.config import SEARCH_TERMS
from devplacepy.services.bot.handles import make_handle
logger = logging.getLogger(__name__)
class BotAuthMixin:
def _generate_handle(self) -> str:
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:
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.account_api_key = ""
self.state.email = self.faker.email()
self.state.password = self.faker.password(length=random.randint(12, 18))
self.state.started_at = datetime.now().isoformat()
self._log(f"Identity: {self.state.username} / {self.state.email}")
curl = await b.url()
if "/feed" in curl:
return True
await b.goto(f"{self.base_url}/auth/login")
curl = await b.url()
if "/feed" in curl:
return True
if await b.fill("#email", self.state.email):
await b._idle(0.2, 0.5)
await b.fill("#password", self.state.password)
await b._idle(0.3, 0.8)
await b.click(".auth-submit")
await asyncio.sleep(2)
curl = await b.url()
if "/feed" in curl:
self._log("Logged in")
return True
self._log("Login failed, registering")
for attempt in range(4):
await b.goto(f"{self.base_url}/auth/signup")
if not await b.fill("#username", self.state.username):
return False
await b._idle(0.2, 0.5)
await b.fill("#email", self.state.email)
await b._idle(0.2, 0.5)
await b.fill("#password", self.state.password)
await b._idle(0.2, 0.5)
await b.fill("#confirm_password", self.state.password)
await b._idle(0.5, 1.0)
await b.click(".auth-submit")
await asyncio.sleep(2)
curl = await b.url()
if "/feed" in curl:
self._log(f"Registered as {self.state.username}")
return True
if attempt >= 2:
base = self._next_handle()
self.state.username = f"{base}{random.randint(10, 9999)}"[:20]
else:
self.state.username = self._next_handle()
self.state.account_api_key = ""
self.state.email = self.faker.email()
self._log(f"Signup failed, retrying as {self.state.username}")
return False
async def _fetch_account_api_key(self) -> str:
if not self.state.username:
return ""
try:
key = await self.b.page.evaluate(
"""async (username) => {
const resp = await fetch(`/profile/${username}`, {
headers: {Accept: 'application/json'},
});
if (!resp.ok) return '';
const data = await resp.json();
return data.api_key || '';
}""",
self.state.username,
)
except Exception as e:
logger.debug("fetch account api key failed: %s", e)
return ""
return (key or "").strip()
async def _adopt_account_api_key(self) -> bool:
for attempt in range(5):
key = await self._fetch_account_api_key()
if key:
if key != self.state.account_api_key:
self.state.account_api_key = key
self._save()
self._log("Adopted account API key for gateway calls")
self.llm.api_key = key
return True
if attempt < 4:
await asyncio.sleep(2)
self._log("Account API key unavailable after retries")
return False

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,140 @@
# retoor <retoor@molodetz.nl>
import logging
import random
import re
from devplacepy.services.bot.config import (
FEED_TOPICS,
GIST_LANGUAGES,
PROJECT_TYPES,
SEARCH_TERMS,
)
logger = logging.getLogger(__name__)
class BotBrowseMixin:
async def _scroll_page(self) -> None:
await self.b.scroll(random.randint(300, 900))
self._log("Scrolled down")
async def _random_nav_click(self) -> bool:
b = self.b
nav_links = b.page.locator(".topnav-links a, .topnav a, a.topnav-link")
count = await nav_links.count()
if count == 0:
nav_links = b.page.locator(
"a[href='/feed'], a[href='/projects'], a[href='/messages'], a[href='/notifications']"
)
count = await nav_links.count()
if count == 0:
return False
picked = random.randrange(count)
try:
text = await nav_links.nth(picked).inner_text()
href = await nav_links.nth(picked).get_attribute("href")
await nav_links.nth(picked).click(timeout=5000)
await b._idle(1.0, 2.0)
self._log(f"Nav: {text.strip()[:30] if text else href}")
return True
except Exception as e:
logger.debug("_random_nav_click failed: %s", e)
return False
async def _browse_section(self) -> bool:
section, query = random.choice(
[
("feed", f"?tab={random.choice(['trending', 'recent', 'following'])}"),
("feed", f"?topic={random.choice(FEED_TOPICS)}"),
("projects", f"?type={random.choice(PROJECT_TYPES)}"),
("gists", f"?language={random.choice(GIST_LANGUAGES)}"),
]
)
await self.b.goto(f"{self.base_url}/{section}{query}")
self._action("BROWSE", f"/{section}{query}")
await self.b._idle(0.8, 1.8)
return True
async def _search(self, section: str = "feed") -> bool:
terms = list(
SEARCH_TERMS.get(self.state.persona, ["python", "rust", "ai", "web"])
)
for href in self.state.known_posts[-10:]:
slug = href.rsplit("/", 1)[-1]
words = [w for w in re.split(r"[-_]", slug) if len(w) > 4]
if words:
terms.append(random.choice(words))
term = random.choice(terms)
await self.b.goto(f"{self.base_url}/{section}?search={term.replace(' ', '+')}")
self.state.searches_run += 1
self._action("SEARCH", f"/{section} '{term}'")
await self.b._idle(0.8, 1.8)
return True
async def _engage_community(self) -> bool:
b = self.b
tab = random.choice(["recent", "recent", "trending"])
await b.goto(f"{self.base_url}/feed?tab={tab}")
await b._idle(0.8, 1.8)
links = b.page.locator("a[href*='/posts/']")
count = await links.count()
if count == 0:
return False
own = set(self.state.own_post_urls)
known = {u.lower() for u in self.state.known_users if u}
me = self.state.username.lower()
ranked: list[tuple[float, int, str]] = []
for i in range(min(count, 30)):
link = links.nth(i)
href = await link.get_attribute("href") or ""
if "/posts/" not in href or len(href) < 20:
continue
if any(href in u for u in own):
continue
score = random.random()
try:
card = link.locator(
"xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
).first
if await card.count() > 0:
author = card.locator("a[href*='/profile/']").first
if await author.count() > 0:
ah = await author.get_attribute("href") or ""
uname = (
ah.split("/profile/")[-1].split("?")[0].split("/")[0].lower()
)
if uname and uname == me:
continue
if uname in known:
score += 2.0
except Exception:
pass
ranked.append((score, i, href))
if not ranked:
return False
ranked.sort(key=lambda r: r[0], reverse=True)
idx, href = ranked[0][1], ranked[0][2]
try:
await links.nth(idx).click(timeout=5000)
except Exception as e:
logger.debug("_engage_community open failed: %s", e)
return False
await b._idle(1.0, 2.0)
if href and href not in self.state.known_posts:
self.state.known_posts.append(href)
await self._read_like_human(3, 60)
acted = False
if await self._comment_on_post():
acted = True
await b._idle(0.5, 1.5)
if random.random() < 0.6 and await self._reply_to_random_comment():
acted = True
await b._idle(0.4, 1.2)
if random.random() < 0.7:
await self._vote_on_feed()
if random.random() < 0.5:
await self._react_to_object()
if acted:
self._action("ENGAGE", f"community thread {href[-40:]}")
return acted

View File

@ -0,0 +1,705 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import random
import re
from typing import Any
from devplacepy.services.bot.config import (
COMMENT_COOLDOWN_MAX_SECONDS,
COMMENT_COOLDOWN_MIN_SECONDS,
MAX_SIBLING_COMMENTS,
REACT_RATE_DEFAULT,
REACT_RATES,
SIBLING_SNIPPET_LEN,
)
logger = logging.getLogger(__name__)
class BotEngageMixin:
async def _vote_on_feed(self) -> bool:
b = self.b
sel = "button.vote-up, button.post-action-btn.vote-up, form[action*='/votes/'] button[type='submit']"
btns = b.page.locator(sel)
count = await btns.count()
if count == 0:
return False
voted_set = set(self.state.voted_post_ids)
known_set = set(self.state.known_users)
candidates: list[tuple[int, str, bool]] = []
for i in range(min(count, 30)):
btn = btns.nth(i)
post_id = ""
try:
form = btn.locator("xpath=ancestor::form[1]").first
if await form.count() > 0:
action = await form.get_attribute("action") or ""
m = re.search(r"/votes/(?:post|comment|gist)/([a-f0-9-]+)", action)
if m:
post_id = m.group(1)
except Exception:
pass
if not post_id:
try:
card = btn.locator(
"xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
).first
if await card.count() > 0:
link = card.locator("a[href*='/posts/']").first
if await link.count() > 0:
href = await link.get_attribute("href") or ""
m = re.search(r"/posts/([^/?#]+)", href)
if m:
post_id = m.group(1)
except Exception:
pass
if post_id and post_id in voted_set:
continue
is_known_author = False
try:
card = btn.locator(
"xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
).first
if await card.count() > 0:
author = card.locator("a[href*='/profile/']").first
if await author.count() > 0:
ah = await author.get_attribute("href") or ""
uname = ah.split("/profile/")[-1].split("?")[0].split("/")[0]
if uname and uname in known_set:
is_known_author = True
except Exception:
pass
candidates.append((i, post_id, is_known_author))
if not candidates:
return False
reciprocal = [c for c in candidates if c[2]]
pool = reciprocal if reciprocal and random.random() < 0.65 else candidates
idx, post_id, _ = random.choice(pool)
try:
await btns.nth(idx).click(timeout=3000)
self.state.votes_cast += 1
if post_id:
self.state.voted_post_ids.append(post_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action(
"VOTE", f"post {post_id[:12] or '?'} (total {self.state.votes_cast})"
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
logger.debug("_vote_on_feed failed: %s", e)
return False
async def _vote_on_comments(self) -> bool:
b = self.b
sel = "button.comment-vote-btn, form[action*='/vote/comment'] button[type='submit']"
btns = b.page.locator(sel)
count = await btns.count()
if count < 2:
return False
idx = random.randrange(count)
try:
await btns.nth(idx).click(timeout=3000)
self.state.votes_cast += 1
self._action("VOTE", f"comment (total {self.state.votes_cast})")
await b._idle(1.0, 2.0)
return True
except Exception as e:
logger.debug("_vote_on_comments failed: %s", e)
return False
async def _is_own_post(self) -> bool:
curl = await self.b.url()
if "/posts/" not in curl:
return False
if curl in self.state.own_post_urls:
return True
author_selectors = [
".post-detail-header a[href*='/profile/']",
".post-detail .post-detail-header a[href*='/profile/']",
".post-author a[href*='/profile/']",
".post-header a[href*='/profile/']",
]
for sel in author_selectors:
loc = self.b.page.locator(sel).first
if await loc.count() == 0:
continue
href = await loc.get_attribute("href") or ""
if "/profile/" not in href:
continue
author = href.split("/profile/")[-1].split("?")[0].split("/")[0].lower()
is_own = author == self.state.username.lower()
if is_own and curl not in self.state.own_post_urls:
self.state.own_post_urls.append(curl)
return is_own
try:
author = await self.b.page.evaluate(
"""(uname) => {
const links = document.querySelectorAll('a[href*="/profile/"]');
for (const a of links) {
let p = a;
let in_nav = false;
while (p && p !== document.body) {
const cn = (p.className || '').toString();
if (p.tagName === 'HEADER' || p.tagName === 'NAV' || /topnav|navbar|sidebar/i.test(cn)) {
in_nav = true;
break;
}
p = p.parentElement;
}
if (in_nav) continue;
const m = a.getAttribute('href').match(/\\/profile\\/([^?/]+)/);
if (m) return m[1];
}
return '';
}""",
self.state.username,
)
except Exception as e:
logger.debug("_is_own_post evaluate failed: %s", e)
return False
if not author:
return False
is_own = author.lower() == self.state.username.lower()
if is_own and curl not in self.state.own_post_urls:
self.state.own_post_urls.append(curl)
return is_own
async def _existing_comments(self) -> list[str]:
bodies = self.b.page.locator(".comment-body")
try:
count = await bodies.count()
except Exception:
return []
if count == 0:
return []
own = self.state.username.lower()
collected: list[str] = []
start = max(0, count - MAX_SIBLING_COMMENTS)
for idx in range(start, count):
node = bodies.nth(idx)
try:
text = (await node.locator(".comment-text").first.inner_text()).strip()
except Exception:
continue
if not text:
continue
author = ""
try:
link = node.locator("a[href*='/profile/']").first
if await link.count() > 0:
href = await link.get_attribute("href") or ""
author = href.split("/profile/")[-1].split("?")[0].split("/")[0]
except Exception:
author = ""
if author and author.lower() == own:
continue
snippet = text[:SIBLING_SNIPPET_LEN]
collected.append(f"{author}: {snippet}" if author else snippet)
return collected
async def _comment_on_post(self) -> bool:
b = self.b
curl = await b.url()
if "/posts/" in curl:
kind = "post"
elif "/gists/" in curl:
kind = "gist"
elif "/news/" in curl:
kind = "news"
elif "/projects/" in curl:
kind = "project"
else:
return False
page_text = (await b.html()).lower()
mentioned_here = f"@{self.state.username.lower()}" in page_text
thread_id = self._thread_id(curl)
if self._session_comments >= self._session_comment_cap and not mentioned_here:
self._log(
f"Comment cap reached ({self._session_comment_cap}/session), skipping"
)
return False
if kind == "post":
if await self._is_own_post() and not mentioned_here:
self._log("Own post, no mention - not commenting")
return False
elif curl in self.state.own_post_urls:
self._log(f"Own {kind} - not commenting")
return False
if (
thread_id
and thread_id in self.state.commented_post_ids
and not mentioned_here
):
self._log(f"Already commented on {thread_id[:12]}, not repeating")
return False
post_body = (
await b.text(".post-content")
or await b.text(".gist-detail-desc")
or await b.text(".news-detail-content")
or await b.text(".news-detail-desc")
or await b.text(".project-detail-desc")
or await b.text(".rendered-content")
or await b.text("article")
or ""
)
if not post_body:
self._log("Comment: no content found")
return False
known = list(
{u for u in self.state.known_users if u and u != self.state.username}
)
mention_target = ""
if known and random.random() < 0.3:
mention_target = random.choice(known)
style = self.llm.pick_comment_style()
siblings = await self._existing_comments()
siblings_block = "\n".join(f"- {c}" for c in siblings)
siblings_text = " ".join(siblings)
comment = ""
for attempt in range(2):
candidate = await self._generate(
self.llm.generate_comment,
post_body[:1500],
self.state.persona,
mention_target,
"",
style,
siblings_block,
)
if not candidate:
continue
verdict = await self._generate(
self.llm.quality_check,
"comment",
candidate,
post_body[:1500],
style,
siblings_text,
)
if verdict is None or verdict[0]:
comment = candidate
break
self.state.quality_rejections += 1
action = "regenerating" if attempt == 0 else "skipping"
self._log(f"Comment quality reject ({verdict[1]}), {action}")
if not comment:
self._log("Comment: no quality candidate")
return False
if mention_target and f"@{mention_target.lower()}" not in comment.lower():
words = comment.split(" ", 1)
if len(words) > 1:
comment = f"{words[0]} @{mention_target} {words[1]}"
else:
comment = f"@{mention_target} {comment}"
comment = comment[:2000]
mention_log = f" @{mention_target}" if mention_target else ""
textarea_sels = [
"form.comment-form textarea[name='content']",
"textarea#comment-content",
"textarea.emoji-picker-target",
"textarea[name='content']",
"#comment-content",
]
ok = False
used_sel = ""
for sel in textarea_sels:
if await self._type_like_human(sel, comment):
ok = True
used_sel = sel
break
if not ok:
self._log("Comment: no textarea found")
return False
self._log(f"Typed comment into {used_sel}")
await b._idle(0.5, 1.5)
submit_sels = [
"form.comment-form button[type='submit']",
"button.btn-primary:has-text('Post')",
"form[action*='/comments/create'] button[type='submit']",
".comment-form button[type='submit']",
]
ok = False
for sel in submit_sels:
if await b.click(sel):
ok = True
break
if ok:
self.state.comments_posted += 1
self._session_comments += 1
if thread_id and thread_id not in self.state.commented_post_ids:
self.state.commented_post_ids.append(thread_id)
if len(self.state.commented_post_ids) > 1000:
self.state.commented_post_ids = self.state.commented_post_ids[-500:]
if kind == "news":
self.state.news_comments += 1
tag = "NEWS" if kind == "news" else "COMMENT"
self._action(
tag,
f"on {kind}{mention_log} [{self.state.comments_posted}]: {comment[:60]}",
)
await asyncio.sleep(
random.uniform(
COMMENT_COOLDOWN_MIN_SECONDS, COMMENT_COOLDOWN_MAX_SECONDS
)
)
return True
self._log("Comment: submit button not found/enabled")
return False
async def _reply_to_random_comment(self) -> bool:
b = self.b
curl = await b.url()
if "/posts/" not in curl:
return False
if self._session_comments >= self._session_comment_cap:
self._log(
f"Comment cap reached ({self._session_comment_cap}/session), skipping reply"
)
return False
comments = b.page.locator(
".comment, li.comment, article.comment, .comment-item"
)
count = await comments.count()
if count == 0:
return False
limit = min(count, 10)
for _ in range(limit):
idx = random.randrange(limit)
target = comments.nth(idx)
try:
text = (await target.inner_text() or "")[:1000]
except Exception:
continue
if not text:
continue
if f"@{self.state.username.lower()}" in text.lower():
continue
mentioner = ""
try:
author_link = target.locator("a[href*='/profile/']").first
if await author_link.count() > 0:
ah = await author_link.get_attribute("href") or ""
if "/profile/" in ah:
mentioner = (
ah.split("/profile/")[-1].split("?")[0].split("/")[0]
)
except Exception:
pass
if not mentioner or mentioner.lower() == self.state.username.lower():
continue
try:
await target.scroll_into_view_if_needed(timeout=2000)
await b._idle(0.3, 0.8)
reply_btn = target.locator(
".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')"
).first
if await reply_btn.count() == 0:
continue
await reply_btn.click(timeout=3000)
await b._idle(0.5, 1.5)
except Exception as e:
logger.debug("reply-btn click failed: %s", e)
continue
post_body = await b.text(".post-content") or await b.text("article") or ""
siblings = [c for c in await self._existing_comments() if text[:60] not in c]
siblings_block = "\n".join(f"- {c}" for c in siblings)
reply = await self._generate(
self.llm.generate_comment,
post_body[:1200],
self.state.persona,
mentioner,
text,
self.llm.pick_comment_style(),
siblings_block,
)
if not reply:
self._log("Reply: generation failed/empty")
return False
if mentioner and f"@{mentioner.lower()}" not in reply.lower():
reply = f"@{mentioner} {reply}"
reply = reply[:2000]
textarea_sels = [
".reply-form textarea[name='content']",
".comment-form textarea[name='content']",
"textarea[name='content']",
]
typed = False
for sel in textarea_sels:
if await self._type_like_human(sel, reply):
typed = True
break
if not typed:
return False
await b._idle(0.5, 1.5)
submit_sels = [
".reply-form button[type='submit']",
"form.comment-form button[type='submit']",
"button.btn-primary:has-text('Reply')",
"button.btn-primary:has-text('Post')",
]
for sel in submit_sels:
if await b.click(sel):
self.state.comments_posted += 1
self._session_comments += 1
if mentioner not in self.state.known_users:
self.state.known_users.append(mentioner)
self._action(
"REPLY",
f"to @{mentioner} [{self.state.comments_posted}]: {reply[:60]}",
)
await asyncio.sleep(
random.uniform(
COMMENT_COOLDOWN_MIN_SECONDS, COMMENT_COOLDOWN_MAX_SECONDS
)
)
return True
return False
return False
async def _vote_on_gist(self) -> bool:
b = self.b
sel = (
"form[action*='/votes/gist/'] button.gist-card-star, "
"form[action*='/votes/gist/'] button[type='submit']"
)
btns = b.page.locator(sel)
count = await btns.count()
if count == 0:
return False
voted = set(self.state.voted_post_ids)
candidates: list[tuple[int, str]] = []
for i in range(min(count, 30)):
gist_id = ""
try:
form = btns.nth(i).locator("xpath=ancestor::form[1]").first
if await form.count() > 0:
action = await form.get_attribute("action") or ""
m = re.search(r"/votes/gist/([a-f0-9-]+)", action)
if m:
gist_id = m.group(1)
except Exception:
pass
if gist_id and gist_id in voted:
continue
candidates.append((i, gist_id))
if not candidates:
return False
idx, gist_id = random.choice(candidates)
try:
await btns.nth(idx).click(timeout=3000)
self.state.votes_cast += 1
if gist_id:
self.state.voted_post_ids.append(gist_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action(
"VOTE", f"gist {gist_id[:12] or '?'} (total {self.state.votes_cast})"
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
logger.debug("_vote_on_gist failed: %s", e)
return False
async def _vote_on_project(self) -> bool:
b = self.b
sel = (
"form[action*='/votes/project/'] button.project-star-btn, "
"form[action*='/votes/project/'] button[type='submit']"
)
btns = b.page.locator(sel)
count = await btns.count()
if count == 0:
return False
voted = set(self.state.voted_post_ids)
candidates: list[tuple[int, str]] = []
for i in range(min(count, 30)):
project_id = ""
try:
form = btns.nth(i).locator("xpath=ancestor::form[1]").first
if await form.count() > 0:
action = await form.get_attribute("action") or ""
m = re.search(r"/votes/project/([a-f0-9-]+)", action)
if m:
project_id = m.group(1)
except Exception:
pass
if project_id and project_id in voted:
continue
candidates.append((i, project_id))
if not candidates:
return False
idx, project_id = random.choice(candidates)
try:
await btns.nth(idx).click(timeout=3000)
self.state.votes_cast += 1
self.state.projects_starred += 1
if project_id:
self.state.voted_post_ids.append(project_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action(
"STAR",
f"project {project_id[:12] or '?'} (total {self.state.votes_cast})",
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
logger.debug("_vote_on_project failed: %s", e)
return False
async def _vote_on_poll(self) -> bool:
b = self.b
polls = b.page.locator(".poll[data-poll-uid]")
count = await polls.count()
if count == 0:
return False
voted = set(self.state.polls_voted)
for i in range(min(count, 20)):
poll = polls.nth(i)
poll_uid = await poll.get_attribute("data-poll-uid") or ""
if not poll_uid or poll_uid in voted:
continue
if await poll.locator(".poll-option.chosen").count() > 0:
self.state.polls_voted.append(poll_uid)
continue
options = poll.locator(".poll-option:not([disabled])")
opt_count = await options.count()
if opt_count == 0:
continue
choice = options.nth(random.randrange(opt_count))
option_uid = await choice.get_attribute("data-option-uid") or ""
try:
await choice.scroll_into_view_if_needed(timeout=2000)
await b._idle(0.3, 0.8)
await choice.click(timeout=3000)
except Exception as e:
logger.debug("_vote_on_poll click failed: %s", e)
continue
try:
await poll.locator(".poll-option.chosen").first.wait_for(
state="visible", timeout=4000
)
except Exception as e:
logger.debug("_vote_on_poll confirm failed: %s", e)
self.state.polls_voted.append(poll_uid)
if len(self.state.polls_voted) > 1000:
self.state.polls_voted = self.state.polls_voted[-500:]
self.state.poll_votes_cast += 1
self._action(
"POLL",
f"voted {poll_uid[:12]} opt {option_uid[:8] or '?'} (total {self.state.poll_votes_cast})",
)
await b._idle(1.0, 2.0)
return True
return False
async def _reaction_content(self, bar: Any) -> str:
try:
container = bar.locator(
"xpath=ancestor::article[contains(concat(' ',normalize-space(@class),' '),' post-card ')][1]"
" | ancestor::div[contains(concat(' ',normalize-space(@class),' '),' comment ')][1]"
).first
if await container.count() > 0:
text = (await container.inner_text() or "").strip()
if text:
return text[:1200]
except Exception as e:
logger.debug("_reaction_content container failed: %s", e)
body = (
await self.b.text(".post-content")
or await self.b.text(".gist-detail-desc")
or await self.b.text(".news-detail-content")
or await self.b.text(".news-detail-desc")
or await self.b.text(".project-detail-desc")
or await self.b.text(".rendered-content")
or await self.b.text("article")
or ""
)
return body[:1200]
async def _react_to_object(self) -> bool:
b = self.b
bars = b.page.locator(".reaction-bar[data-reaction-uid]")
count = await bars.count()
if count == 0:
return False
decided = set(self.state.reacted_targets)
react_rate = REACT_RATES.get(self.state.persona, REACT_RATE_DEFAULT)
for i in range(min(count, 20)):
bar = bars.nth(i)
uid = await bar.get_attribute("data-reaction-uid") or ""
rtype = await bar.get_attribute("data-reaction-type") or ""
if not uid or uid in decided:
continue
self.state.reacted_targets.append(uid)
if len(self.state.reacted_targets) > 2000:
self.state.reacted_targets = self.state.reacted_targets[-1000:]
if random.random() > react_rate:
self._log(f"Reaction: skipping {rtype} {uid[:12]} (persona reticence)")
return False
snippet = await self._reaction_content(bar)
if not snippet:
self._log(f"Reaction: no content for {rtype} {uid[:12]}")
return False
emoji = await self._generate(
self.llm.select_reaction, snippet, self.state.persona
)
if not emoji:
self._log(f"Reaction: nothing fitting for {rtype} {uid[:12]}")
return False
add_btn = bar.locator(".reaction-add-btn").first
if await add_btn.count() == 0:
return False
palette_btn = bar.locator(
f".reaction-palette-btn[data-reaction-emoji='{emoji}']"
).first
if await palette_btn.count() == 0:
return False
try:
await add_btn.scroll_into_view_if_needed(timeout=2000)
await b._idle(0.2, 0.6)
await add_btn.click(timeout=3000)
await b._idle(0.3, 0.7)
await palette_btn.click(timeout=3000)
except Exception as e:
logger.debug("_react_to_object click failed: %s", e)
return False
try:
await bar.locator(".reaction-chip.reacted").first.wait_for(
state="visible", timeout=4000
)
except Exception as e:
logger.debug("_react_to_object confirm failed: %s", e)
self.state.reactions_cast += 1
self._action(
"REACT",
f"{emoji} on {rtype} {uid[:12]} (total {self.state.reactions_cast})",
)
await b._idle(1.0, 2.0)
return True
return False

View File

@ -0,0 +1,157 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import random
import re
import time
from typing import Any, Optional
from devplacepy.services.bot.config import persona_article_score, pick_category
logger = logging.getLogger(__name__)
class BotHelpersMixin:
def _identity(self) -> str:
return f"{self.state.username or '?'}/{self.state.persona}"
def _cost_tag(self) -> str:
return f"${self.llm.total_cost:.4f} / {self.llm.total_calls} calls"
def _notify(self, line: str) -> None:
if self.on_event:
try:
self.on_event(line)
except Exception as e:
logger.debug("on_event failed: %s", e)
@staticmethod
def _thread_id(url: str) -> str:
m = re.search(r"/(?:posts|gists|news|projects)/([^/?#]+)", url)
return m.group(1) if m else ""
def _sync_cost(self) -> None:
self.state.total_cost = self.llm.total_cost
self.state.total_calls = self.llm.total_calls
self.state.total_in_tokens = self.llm.total_in_tokens
self.state.total_out_tokens = self.llm.total_out_tokens
def _save(self) -> None:
self._sync_cost()
self.state.save(self.state_path)
def _log(self, msg: str) -> None:
self.state.log.append(f"[{time.strftime('%H:%M:%S')}] {msg}")
if len(self.state.log) > 500:
self.state.log = self.state.log[-250:]
logger.info("[%s] %s", self._identity(), msg)
def _action(self, tag: str, detail: str) -> None:
line = f"{tag}: {detail}"
self.state.log.append(f"[{time.strftime('%H:%M:%S')}] {line}")
if len(self.state.log) > 500:
self.state.log = self.state.log[-250:]
logger.info("[%s] %s (%s)", self._identity(), line, self._cost_tag())
self._notify(f"[{self._identity()}] {line}")
self.b.note(action=line)
asyncio.create_task(self.b.capture(tag.lower(), force=True))
def _bind_monitor(self) -> None:
self.b.bind_monitor(
self._slot,
username=self.state.username,
persona=self.state.persona,
)
async def _generate(self, fn: Any, *args: Any) -> Any:
try:
return await asyncio.to_thread(fn, *args)
except Exception as e:
logger.warning(
"LLM generation failed (%s): %s", getattr(fn, "__name__", fn), e
)
return None
def _cost_summary(self, label: str) -> None:
self._notify(
f"[{self._identity()}] cost ({label}): ${self.llm.total_cost:.6f} "
f"over {self.llm.total_calls} calls (in={self.llm.total_in_tokens} out={self.llm.total_out_tokens})"
)
def _session_summary(self, mood: str, actions: int) -> None:
s = self.state
sess_cost = self.llm.total_cost - self._session_start_cost
sess_calls = self.llm.total_calls - self._session_start_calls
self._notify(
f"[{self._identity()}] session done mood={mood} actions={actions} "
f"posts={s.created_posts} comments={s.comments_posted} votes={s.votes_cast} "
f"session=${sess_cost:.4f}/{sess_calls} lifetime=${self.llm.total_cost:.4f}"
)
def _session_banner(self, mood: str, session_len: int) -> None:
self._notify(
f"[{self._identity()}] session start mood={mood} target~{session_len} actions"
)
def _pick_article(self, articles: list[dict]) -> Optional[dict]:
if not articles:
return None
ranked = sorted(
articles,
key=lambda a: persona_article_score(a, self.state.persona)
+ random.random(),
reverse=True,
)
top = ranked[: max(3, len(ranked) // 3)]
return random.choice(top)
async def _refill_cache_async(self, kind: str) -> None:
try:
if kind == "post":
articles = self.news.fetch()
a = self._pick_article(articles)
if a:
desc = a.get("description", "")[:500]
cat = pick_category(self.state.persona)
post_title = await asyncio.to_thread(
self.llm.generate_post_title,
a["title"],
self.state.persona,
cat,
)
content = await asyncio.to_thread(
self.llm.generate_post,
a["title"],
desc,
self.state.persona,
cat,
self.state.recent_post_titles,
)
self._post_cache.append(
(a["title"], post_title or "", desc, content, cat)
)
elif kind == "project":
t = await asyncio.to_thread(
self.llm.generate_project_title, self.state.persona
)
desc = await asyncio.to_thread(
self.llm.generate_project_desc, t, self.state.persona
)
self._project_cache.append((t, desc))
elif kind == "issue":
topic = random.choice(["UI glitch", "Broken link", "Performance issue"])
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)
async def _warm_cache(self) -> None:
self._log("Warming content cache (background)...")
self._post_cache = []
self._project_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("issue"))

View File

@ -0,0 +1,178 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import random
import re
from typing import Optional
logger = logging.getLogger(__name__)
class BotHumanMixin:
async def _read_like_human(self, min_s: float = 3.0, max_s: float = 90.0) -> None:
if not self.state.reading_speed_wpm:
self.state.reading_speed_wpm = random.randint(180, 320)
wpm = self.state.reading_speed_wpm
sec_per_word = 60.0 / wpm
curl = await self.b.url()
if "/posts/" in curl:
body = (
await self.b.text("article") or await self.b.text(".post-content") or ""
)
wc = len(body.split())
jitter = random.uniform(0.7, 1.25)
pause = min(max(wc * sec_per_word * jitter, min_s), max_s)
self._log(f"Reading {wc} words ({pause:.0f}s @ {wpm}wpm)")
scrolls = max(1, int(pause / 12))
chunk = pause / scrolls
for _ in range(scrolls):
await asyncio.sleep(chunk)
if random.random() < 0.55:
await self.b.scroll(random.randint(150, 450))
else:
await asyncio.sleep(random.uniform(1.0, 4.0))
async def _type_like_human(self, sel: str, val: str) -> bool:
el = self.b.page.locator(sel).first
if await el.count() == 0:
return False
try:
await el.click(timeout=3000)
await self.b._idle(0.1, 0.3)
await el.fill("")
words = val.split(" ")
for i, word in enumerate(words):
for ch in word:
delay = random.randint(15, 80)
if ch in ".!?":
delay = random.randint(200, 500)
elif ch == ",":
delay = random.randint(100, 250)
await self.b.page.keyboard.type(ch, delay=delay)
if i < len(words) - 1:
space_delay = random.randint(30, 120)
await self.b.page.keyboard.type(" ", delay=space_delay)
if random.random() < 0.08:
await self.b._idle(0.3, 1.0)
if (i + 1) % 5 == 0 and random.random() < 0.3:
await self.b._idle(0.2, 0.8)
return True
except Exception as e:
logger.debug("_type_like_human failed for '%s': %s", sel, e)
return False
async def _fatigue_check(self, session_actions: int) -> Optional[str]:
if session_actions < 5:
return None
chance = session_actions / 100.0
if random.random() < chance:
if session_actions < 10:
return "casual"
elif session_actions < 20:
return "normal"
else:
return "deep"
return None
async def _session_type(self) -> tuple[str, int, tuple]:
roll = random.random()
if roll < 0.25:
return ("lurking", random.randint(2, 5), (3600, 21600))
elif roll < 0.60:
return ("casual", random.randint(4, 10), (1800, 7200))
elif roll < 0.85:
return ("normal", random.randint(8, 18), (3600, 14400))
else:
return ("deep", random.randint(15, 30), (14400, 43200))
async def _ensure_identity(self) -> None:
if self.state.identity:
return
identity = await self._generate(
self.llm.generate_identity, self.state.persona
)
if identity:
self.state.identity = identity
self._log(
f"Identity card: {identity.get('name', '?')} ({self.state.persona})"
)
self._save()
async def _page_summary(self, page: str) -> str:
b = self.b
title = ""
for sel in ("h1", ".post-title", ".content-title", "title"):
title = (await b.text(sel) or "").strip()
if title:
break
summary = f'title="{title[:100]}"' if title else "no title"
try:
comments = await b.page.locator(".comment, .comment-item").count()
if comments:
summary += f", {comments} comments"
except Exception as e:
logger.debug("page summary count failed: %s", e)
return summary
async def _unread_notification_count(self) -> int:
try:
badge = self.b.page.locator(
"a[data-counter='notifications'] .nav-badge[data-counter-badge]"
).first
if await badge.count() == 0:
return 0
if await badge.get_attribute("hidden") is not None:
return 0
text = (await badge.inner_text() or "").strip()
digits = re.sub(r"[^0-9]", "", text)
return int(digits) if digits else 0
except Exception as e:
logger.debug("_unread_notification_count failed: %s", e)
return 0
async def _page_state(
self,
curl: str,
page_text: str,
*,
did_post: bool,
follows: int,
gists: int,
can_project: bool,
can_post: bool,
can_message: bool,
) -> dict:
pages = [
("post", "/posts/" in curl),
("profile", "/profile/" in curl),
("news_detail", "/news/" in curl),
("news_list", bool(re.search(r"/news(\?|$)", curl))),
("project_detail", "/projects/" in curl),
("projects_list", bool(re.search(r"/projects(\?|$)", curl))),
("gist_detail", "/gists/" in curl),
("gists_list", bool(re.search(r"/gists(\?|$)", curl))),
("notifications", "/notifications" in curl),
]
page = next((name for name, hit in pages if hit), "feed")
own_post = page == "post" and await self._is_own_post()
mentioned = own_post and f"@{self.state.username.lower()}" in page_text
own_profile = (
page == "profile"
and f"/profile/{self.state.username.lower()}" in curl.lower()
)
return {
"page": page,
"url": curl,
"visible": await self._page_summary(page),
"own_post": own_post,
"own_profile": own_profile,
"mentioned": mentioned,
"did_post": did_post,
"follows": follows,
"gists": gists,
"can_project": can_project,
"can_post": can_post,
"can_message": can_message,
"unread_notifications": await self._unread_notification_count(),
}

View File

@ -0,0 +1,514 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import random
import re
from devplacepy.services.bot.config import (
NOTIFICATION_CHECK_PROB,
SESSION_COMMENT_CAP_MAX,
SESSION_COMMENT_CAP_MIN,
)
logger = logging.getLogger(__name__)
class BotLoopMixin:
async def _cycle(
self,
mood: str = "normal",
*,
session_posted: bool = False,
session_projects: int = 0,
session_follows: int = 0,
session_gists: int = 0,
can_project: bool = False,
can_post: bool = False,
can_message: bool = False,
) -> tuple[int, bool, int, int, int]:
b = self.b
curl = await b.url()
page_text = (await b.html()).lower()
on_post = "/posts/" in curl
on_profile = "/profile/" in curl
on_project_detail = "/projects/" in curl
on_projects_list = bool(re.search(r"/projects(\?|$)", curl))
on_gist_detail = "/gists/" in curl
on_gists_list = bool(re.search(r"/gists(\?|$)", curl))
on_news_detail = "/news/" in curl
on_news_list = bool(re.search(r"/news(\?|$)", curl))
on_notifications = "/notifications" in curl
actions = 0
did_post = session_posted
projs = session_projects
follows = session_follows
gists = session_gists
msgs = 0
mood_factor = {"lurking": 0.3, "casual": 0.6, "normal": 1.0, "deep": 1.5}
burst = max(1, int(random.gauss(3, 1) * mood_factor[mood]))
for _ in range(burst):
if mood == "lurking" and not on_post and random.random() < 0.4:
await self._scroll_page()
await b._idle(0.3, 1.0)
actions += 1
curl = await b.url()
page_text = (await b.html()).lower()
on_post = "/posts/" in curl
continue
if on_post:
page_text = (await b.html()).lower()
own_post = await self._is_own_post()
mentioned = own_post and f"@{self.state.username.lower()}" in page_text
await self._read_like_human(4, 90)
if own_post and not mentioned:
self._log("Own post, no mention - skipping interactions")
await self._random_nav_click()
await b._idle(1.0, 2.5)
else:
comment_p = {
"lurking": 0.5,
"casual": 0.75,
"normal": 0.85,
"deep": 0.9,
}[mood]
if random.random() < comment_p:
if await self._comment_on_post():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.5:
if await self._reply_to_random_comment():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.7:
if await self._vote_on_feed():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.3:
if await self._vote_on_comments():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.6:
if await self._vote_on_poll():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.6:
if await self._react_to_object():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.75:
await b.goto(f"{self.base_url}/feed")
await b._idle(0.8, 2.0)
elif random.random() < 0.3:
await self._random_nav_click()
await b._idle(1.0, 2.5)
elif on_profile:
if not follows and random.random() < 0.4:
if await self._follow_user():
follows += 1
actions += 1
await b._idle(0.5, 1.5)
if can_message and not msgs and random.random() < 0.3:
if await self._send_message():
msgs += 1
actions += 1
await b._idle(0.5, 1.5)
await self._scroll_page()
await b._idle(0.5, 1.0)
if random.random() < 0.3:
if await self._click_post_link():
actions += 1
await self._random_nav_click()
await b._idle(1.0, 2.0)
actions += 1
elif on_news_detail:
await self._read_like_human(4, 60)
comment_p = {"lurking": 0.4, "casual": 0.6, "normal": 0.7, "deep": 0.8}[
mood
]
if random.random() < comment_p:
if await self._comment_on_post():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.7:
await b.goto(f"{self.base_url}/feed")
await b._idle(0.8, 1.8)
else:
await self._random_nav_click()
await b._idle(1.0, 2.5)
elif on_news_list:
await self._scroll_page()
await b._idle(0.4, 1.0)
if random.random() < 0.7:
if await self._open_content("news"):
actions += 1
else:
await self._random_nav_click()
await b._idle(0.8, 1.8)
actions += 1
elif on_project_detail:
await self._read_like_human(4, 60)
if random.random() < 0.6:
if await self._vote_on_project():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.5:
if await self._react_to_object():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.4:
if await self._comment_on_post():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.7:
await b.goto(f"{self.base_url}/projects")
await b._idle(0.8, 1.8)
else:
await self._random_nav_click()
await b._idle(1.0, 2.5)
elif on_projects_list:
if random.random() < 0.55:
if await self._vote_on_project():
actions += 1
await b._idle(0.5, 1.5)
if can_project and projs < 1 and random.random() < 0.3:
if await self._click_create_project():
projs += 1
actions += 1
await b._idle(0.5, 1.0)
elif random.random() < 0.6:
if await self._open_content("project"):
actions += 1
else:
await self._scroll_page()
await b._idle(0.8, 1.8)
actions += 1
elif on_gist_detail:
await self._read_like_human(4, 60)
if random.random() < 0.45:
if await self._comment_on_post():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.6:
if await self._vote_on_feed():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.5:
if await self._react_to_object():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.4:
await self._random_nav_click()
await b._idle(1.0, 2.5)
elif on_gists_list:
if random.random() < 0.6:
if await self._vote_on_gist():
actions += 1
await b._idle(0.5, 1.5)
if gists < 1 and random.random() < 0.25:
if await self._create_gist():
gists += 1
actions += 1
await b._idle(0.5, 1.0)
elif random.random() < 0.5:
if await self._click_gist_link():
actions += 1
else:
await self._scroll_page()
await b._idle(0.8, 1.8)
actions += 1
elif on_notifications:
await self._check_notifications()
await b._idle(1.0, 2.0)
await self._random_nav_click()
actions += 1
else:
clicked = False
if random.random() < 0.3:
acted = await self._react_to_object()
if not acted:
acted = await self._vote_on_poll()
if acted:
actions += 1
clicked = True
if not clicked and random.random() < 0.12:
if random.random() < 0.5:
await self._search(
"feed" if random.random() < 0.7 else "projects"
)
else:
await self._browse_section()
actions += 1
clicked = True
if not clicked and random.random() < 0.85:
if await self._click_post_link():
clicked = True
actions += 1
if not clicked and can_post and not did_post and random.random() < 0.5:
if await self._create_post():
did_post = True
actions += 1
clicked = True
if not clicked and random.random() < 0.2:
if await self._click_profile_link():
clicked = True
actions += 1
if not clicked and gists < 1 and random.random() < 0.1:
if await self._create_gist():
gists += 1
actions += 1
clicked = True
if not clicked:
if await self._vote_on_feed():
actions += 1
clicked = True
if not clicked:
if await self._random_nav_click():
clicked = True
if not clicked:
await self._scroll_page()
actions += 1
await b._idle(1.0, 2.5)
curl = await b.url()
page_text = (await b.html()).lower()
on_post = "/posts/" in curl
on_profile = "/profile/" in curl
on_project_detail = "/projects/" in curl
on_projects_list = bool(re.search(r"/projects(\?|$)", curl))
on_gist_detail = "/gists/" in curl
on_gists_list = bool(re.search(r"/gists(\?|$)", curl))
on_news_detail = "/news/" in curl
on_news_list = bool(re.search(r"/news(\?|$)", curl))
on_notifications = "/notifications" in curl
return actions, did_post, projs, follows, gists
async def run_forever(self, action_limit: int = 0) -> None:
b = self.b
self._save()
lifetime_actions = 0
crash_streak = 0
session_number = 0
sessions_since_project = 99
sessions_since_post = 99
sessions_since_message = 99
self._bind_monitor()
if self._startup_jitter:
delay = random.uniform(0, self._startup_jitter)
self.b.note(status=f"warming up, first session in {delay / 60:.0f}m")
self._log(f"Startup jitter: first session in {delay / 60:.1f}m")
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
self._save()
raise
while True:
session_number += 1
mood, session_len, break_range = await self._session_type()
self._session_start_cost = self.llm.total_cost
self._session_start_calls = self.llm.total_calls
self._session_banner(mood, session_len)
self._session_history = []
self._session_energy = 0.5
self._session_stop_after = 0
self._session_comments = 0
self._session_comment_cap = random.randint(
SESSION_COMMENT_CAP_MIN, SESSION_COMMENT_CAP_MAX
)
self._session_mention_replies = 0
use_ai = False
try:
await b.launch()
except Exception as e:
self._log(f"Launch failed: {e}")
crash_streak += 1
wait = min(30 * crash_streak, 300)
await asyncio.sleep(wait)
continue
session_ok = False
try:
if not await self._ensure_auth():
self._log("Auth failed")
await b.close()
await asyncio.sleep(60)
continue
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()
if self._ai_decisions:
await self._ensure_identity()
use_ai = self._ai_decisions and bool(self.state.identity)
await self._warm_cache()
self._log(f"Session active ({mood}) as {self._identity()}")
if not self.state.profile_filled:
await self._update_profile()
await b.goto(f"{self.base_url}/feed")
await b._idle(0.8, 2.0)
unread = await self._unread_notification_count()
if unread:
self._log(f"Unread notifications badge: {unread}")
check_prob = NOTIFICATION_CHECK_PROB.get(mood, 1.0)
want_notifications = mood in ("normal", "deep") or (
unread > 0 and random.random() < check_prob
)
if mood in ("normal", "deep"):
await self._check_messages()
await b._idle(0.5, 1.5)
if want_notifications:
await self._check_notifications()
await b._idle(1.0, 3.0)
if mood in ("normal", "deep"):
await self._engage_community()
await b._idle(0.8, 2.0)
await b.goto(f"{self.base_url}/feed")
session_actions = 0
max_actions = session_len + random.randint(0, 5)
session_posted = False
session_projects = 0
session_follows = 0
session_gists = 0
session_messages_start = self.state.messages_sent
sessions_since_project += 1
sessions_since_post += 1
sessions_since_message += 1
can_create_project = (
sessions_since_project >= 3 and random.random() < 0.3
)
can_create_post = sessions_since_post >= 2 and random.random() < 0.5
can_create_message = (
sessions_since_message >= 2 and random.random() < 0.4
)
while session_actions < max_actions:
if action_limit and lifetime_actions >= action_limit:
self._log(f"Hit max actions ({action_limit}), stopping")
self._save()
self._cost_summary("final")
return
(
taken,
session_posted,
session_projects,
session_follows,
session_gists,
) = await (self._cycle_ai if use_ai else self._cycle)(
mood=mood,
session_posted=session_posted,
session_projects=session_projects,
session_follows=session_follows,
session_gists=session_gists,
can_project=can_create_project,
can_post=can_create_post,
can_message=can_create_message,
)
if session_posted:
sessions_since_post = 0
if session_projects:
sessions_since_project = 0
if self.state.messages_sent > session_messages_start:
sessions_since_message = 0
session_actions += max(taken, 1)
lifetime_actions += max(taken, 1)
self._save()
crash_streak = 0
if use_ai:
if (
self._session_stop_after
and session_actions >= self._session_stop_after
):
self._log(
f"Energy spent (stop_after={self._session_stop_after}): ending session"
)
break
intra_pause = self._scaled_pause()
else:
fatigue = await self._fatigue_check(session_actions)
if fatigue:
self._log(f"Fatigue ({fatigue}): ending session")
break
intra_pause = random.randint(self._pause_min, self._pause_max)
self._log(
f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}"
)
await asyncio.sleep(intra_pause)
session_ok = session_actions > 0
self._session_summary(mood, session_actions)
except asyncio.CancelledError:
self._save()
try:
await b.close()
except Exception as e:
logger.debug("close on cancel failed: %s", e)
raise
except Exception as e:
crash_streak += 1
self._log(f"Session crash ({crash_streak}): {e}")
self._save()
finally:
try:
await b.close()
except Exception as e:
logger.debug("close in finally failed: %s", e)
if crash_streak > 3:
self._log("Too many consecutive crashes, exiting")
self._save()
self._cost_summary("final")
break
if not session_ok and crash_streak > 0:
break_s = random.randint(30, 120)
self._log(f"Recovery wait {break_s}s")
else:
break_s = max(5, int(random.randint(*break_range) * self._break_scale))
if use_ai:
factor = 1.5 - 1.0 * self._session_energy
break_s = max(60, int(break_s * factor))
break_h = break_s / 3600
self._log(f"Session done. Break {break_h:.1f}h")
self._save()
try:
await asyncio.sleep(break_s)
except asyncio.CancelledError:
self._save()
raise
self._cost_summary("final")
self._log("Bot process exiting")

View File

@ -0,0 +1,345 @@
# retoor <retoor@molodetz.nl>
import asyncio
import hashlib
import logging
import random
from devplacepy.services.bot.config import pick_category
logger = logging.getLogger(__name__)
class BotPostingMixin:
async def _create_gist(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/gists")
if not await b.click("#create-gist-btn, .gists-fab, .feed-fab"):
self._log("Create gist: trigger not found")
return False
await b._idle(0.8, 1.5)
if await b.page.locator("#create-gist-modal").count() == 0:
self._log("Create gist: modal not found")
return False
generated = None
for attempt in range(2):
candidate = await self._generate(self.llm.generate_gist, self.state.persona)
if not candidate:
continue
cand_title, cand_desc, cand_lang, cand_code = candidate
if not cand_code.strip():
continue
verdict = await self._generate(
self.llm.gist_quality_check, cand_title, cand_code, cand_lang
)
if verdict is None or verdict[0]:
generated = candidate
break
self.state.quality_rejections += 1
action = "regenerating" if attempt == 0 else "skipping"
self._log(f"Gist quality reject ({verdict[1]}), {action}")
if not generated:
self._log("Create gist: no quality candidate")
return False
title, desc, language, code = generated
title = self.llm.strip_md(title)[:200] or title[:200]
if not await b.fill("#gist-title", title):
self._log("Create gist: title field not found")
return False
await b._idle(0.2, 0.5)
await b.fill("#gist-description", desc[:500])
await b._idle(0.2, 0.5)
try:
await b.page.select_option("#gist-language", language)
except Exception as e:
logger.debug("gist language select failed: %s", e)
await b._idle(0.3, 0.7)
cm = b.page.locator("#create-gist-modal .CodeMirror")
if await cm.count() == 0:
self._log("Create gist: code editor not found")
return False
try:
await cm.first.click()
await b._idle(0.3, 0.6)
await b.page.evaluate(
"""(code) => {
const el = document.querySelector('#create-gist-modal .CodeMirror');
if (el && el.CodeMirror) { el.CodeMirror.setValue(code); el.CodeMirror.save(); }
const ta = document.querySelector('#gist-source-editor');
if (ta) { ta.value = code; ta.dispatchEvent(new Event('input', {bubbles: true})); }
}""",
code,
)
except Exception as e:
logger.debug("gist code fill failed: %s", e)
return False
await b._idle(0.5, 1.2)
ok = await b.click(
"#create-gist-modal button[type='submit'], #create-gist-modal .btn-primary"
)
if not ok:
self._log("Create gist: submit not found")
return False
await asyncio.sleep(2)
url = await b.url()
if "/gists/" in url:
if url not in self.state.own_post_urls:
self.state.own_post_urls.append(url)
self.state.gists_created += 1
self._action(
"GIST",
f"{title[:40]} [{language}] [{self.state.gists_created}] -> {url[-50:]}",
)
return True
self._log("Create gist: no redirect to gist detail")
return False
async def _create_post(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/feed")
fab_sel = "#create-post-btn, .feed-fab, button.feed-fab, a.feed-fab"
ok = await b.click(fab_sel)
if not ok:
self._log("Create post: FAB button not found")
return False
self._log("Create post: FAB clicked, waiting for modal")
await b._idle(1.0, 2.0)
modal = await b.page.locator("#create-post-modal").count()
if modal == 0:
self._log("Create post: modal #create-post-modal not in DOM")
return False
hidden = await b.page.locator("#create-post-modal").get_attribute("class") or ""
if "hidden" in hidden or await b.page.locator("#create-post-modal").is_hidden():
self._log("Create post: modal is hidden")
return False
self._log("Create post: modal visible")
article_title = ""
article_desc = ""
post_title = ""
if self._post_cache:
article_title, post_title, article_desc, content, topic = (
self._post_cache.pop(0)
)
asyncio.create_task(self._refill_cache_async("post"))
else:
articles = self.news.fetch()
topic = pick_category(self.state.persona)
article = self.registry.reserve_unused(
articles,
self.state.username,
self.llm.clean,
topic,
self.state.persona,
)
if not article:
self._log("Create post: no unused articles available")
return False
desc = article.get("description", "")[:500]
post_title = await self._generate(
self.llm.generate_post_title,
article["title"],
self.state.persona,
topic,
)
content = await self._generate(
self.llm.generate_post,
article["title"],
desc,
self.state.persona,
topic,
self.state.recent_post_titles,
)
if not content:
self._log("Create post: generation failed/empty")
return False
article_title = article["title"]
article_desc = desc
verdict = await self._generate(
self.llm.quality_check, "post", content, article_desc
)
if verdict is not None and not verdict[0]:
self.state.quality_rejections += 1
self._log(f"Post quality reject ({verdict[1]}), regenerating")
content = await self._generate(
self.llm.generate_post,
article_title,
article_desc,
self.state.persona,
topic,
self.state.recent_post_titles,
)
if not content:
self._log("Create post: regeneration failed/empty")
return False
verdict = await self._generate(
self.llm.quality_check, "post", content, article_desc
)
if verdict is not None and not verdict[0]:
self.state.quality_rejections += 1
self._log(f"Post quality reject ({verdict[1]}), skipping")
return False
content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
if content_hash in self.state.posted_content_hashes:
self._log(f"Create post: duplicate content {content_hash}, skipping")
return False
clean_title = self.llm.clean(article_title[:200]) if article_title else ""
if clean_title:
if not self.registry.reserve(clean_title, self.state.username, topic):
self._log(
f"Create post: article '{clean_title[:50]}...' already covered, skipping"
)
return False
type_title = self.llm.strip_md(post_title)[:120] if post_title else ""
if not type_title:
type_title = clean_title
ts = f"#create-post-modal input[name='topic'][value='{topic}']"
if await b.page.locator(ts).count() == 0:
self._log(f"Create post: topic radio '{topic}' not found")
else:
await b.click(ts)
await b._idle(0.2, 0.5)
if type_title:
ok = await b.fill("#create-post-modal input[name='title']", type_title)
if not ok:
self._log("Create post: title input not found")
await b._idle(0.2, 0.4)
content_sel = "#create-post-modal textarea[name='content']"
if await b.page.locator(content_sel).count() == 0:
self._log("Create post: content textarea not found")
return False
if not await self._type_like_human(content_sel, content[:5000]):
self._log("Create post: typing content into modal failed")
return False
await b._idle(0.5, 1.5)
submit_sels = [
"#create-post-modal button[type='submit']",
"#create-post-modal .btn-primary",
".modal-overlay#create-post-modal button[type='submit']",
"#create-post-modal button:has-text('Post')",
]
ok = False
for sel in submit_sels:
if await b.click(sel):
ok = True
break
if not ok:
self._log("Create post: submit button not found")
return False
post_url = await b.url()
for _ in range(12):
if "/posts/" in post_url:
break
await asyncio.sleep(1)
post_url = await b.url()
if "/posts/" not in post_url:
self._log(f"Create post: submit did not create a post (url={post_url[-45:]})")
return False
self.state.created_posts += 1
self.state.posted_content_hashes.append(content_hash)
if type_title:
self.state.recent_post_titles.append(type_title)
self.state.recent_post_titles = self.state.recent_post_titles[-8:]
if post_url not in self.state.own_post_urls:
self.state.own_post_urls.append(post_url)
if post_url not in self.state.known_posts:
self.state.known_posts.append(post_url)
self._action(
"POST",
f"{topic} [{self.state.created_posts}]: {type_title[:50] or content[:50]} -> {post_url[-45:]}",
)
return True
async def _click_post_link(self) -> bool:
b = self.b
links = b.page.locator("a[href*='/posts/']")
count = await links.count()
if count == 0:
return False
candidates = []
for i in range(count):
h = await links.nth(i).get_attribute("href")
if h and "/posts/" in h and len(h) > 20:
candidates.append(i)
if not candidates:
return False
idx = random.choice(candidates)
href = await links.nth(idx).get_attribute("href")
try:
await links.nth(idx).click(timeout=5000)
await b._idle(1.0, 2.0)
if href and href not in self.state.known_posts:
self.state.known_posts.append(href)
self._action("OPEN", f"post {href[-45:] if href else '?'}")
return True
except Exception as e:
logger.debug("_click_post_link failed: %s", e)
return False
async def _click_gist_link(self) -> bool:
b = self.b
links = b.page.locator("a[href*='/gists/']")
count = await links.count()
if count == 0:
return False
candidates = []
for i in range(count):
h = await links.nth(i).get_attribute("href")
if h and "/gists/" in h and "?" not in h and h.rstrip("/") != "/gists":
candidates.append(i)
if not candidates:
return False
idx = random.choice(candidates)
href = await links.nth(idx).get_attribute("href")
try:
await links.nth(idx).click(timeout=5000)
await b._idle(1.0, 2.0)
self._action("OPEN", f"gist {href[-45:] if href else '?'}")
return True
except Exception as e:
logger.debug("_click_gist_link failed: %s", e)
return False
async def _open_content(self, kind: str) -> bool:
b = self.b
base = "/news/" if kind == "news" else f"/{kind}s/"
links = b.page.locator(f"a[href*='{base}']")
count = await links.count()
if count == 0:
return False
candidates = []
for i in range(count):
h = await links.nth(i).get_attribute("href")
if h and base in h and "?" not in h and h.rstrip("/") != base.rstrip("/"):
candidates.append(i)
if not candidates:
return False
idx = random.choice(candidates)
href = await links.nth(idx).get_attribute("href")
try:
await links.nth(idx).click(timeout=5000)
await b._idle(1.0, 2.0)
self._action("OPEN", f"{kind} {href[-45:] if href else '?'}")
return True
except Exception as e:
logger.debug("_open_content(%s) failed: %s", kind, e)
return False

View File

@ -0,0 +1,624 @@
# retoor <retoor@molodetz.nl>
import asyncio
import hashlib
import logging
import random
from devplacepy.services.bot.config import (
MAX_THREAD_REPLIES,
MENTION_REPLIES_PER_SESSION,
PROJECT_STATUSES,
)
logger = logging.getLogger(__name__)
class BotSocialMixin:
async def _click_profile_link(self) -> bool:
b = self.b
links = b.page.locator("a[href*='/profile/']")
count = await links.count()
if count == 0:
return False
for i in range(count):
h = await links.nth(i).get_attribute("href")
if h and self.state.username not in h:
try:
await links.nth(i).click(timeout=5000)
await b._idle(1.0, 2.0)
uname = h.split("/profile/")[-1].split("?")[0]
if uname not in self.state.known_users:
self.state.known_users.append(uname)
self.state.profiles_viewed += 1
self._action(
"PROFILE", f"viewed @{uname} [{self.state.profiles_viewed}]"
)
return True
except Exception as e:
logger.debug("_click_profile_link failed: %s", e)
continue
return False
async def _click_create_project(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/projects")
ok = await b.click("#create-project-btn")
if not ok:
return False
await b._idle(0.8, 1.5)
if not await b.text("#create-project-modal"):
return False
if self._project_cache:
raw_title, desc = self._project_cache.pop(0)
asyncio.create_task(self._refill_cache_async("project"))
else:
raw_title = await self._generate(
self.llm.generate_project_title, self.state.persona
)
if not raw_title:
self._log("Create project: title generation failed")
return False
raw_title = raw_title[:200]
desc = await self._generate(
self.llm.generate_project_desc, raw_title, self.state.persona
)
if not desc:
self._log("Create project: description generation failed")
return False
desc = desc[:5000]
title = self.llm.strip_md(raw_title)[:120] or raw_title[:120]
types = ["game", "software", "mobile_app", "website", "game_asset"]
ptype = random.choice(types)
status = random.choice(PROJECT_STATUSES)
await b.fill("#title", title)
await b._idle(0.2, 0.5)
await b.fill("#description", desc)
await b._idle(0.2, 0.5)
await b.click(f"input[name='project_type'][value='{ptype}']")
await b._idle(0.2, 0.5)
await b.click(f"input[name='status'][value='{status}']")
await b._idle(0.2, 0.5)
ok = await b.click("#create-project-modal .btn-primary")
if ok:
self.state.projects_created += 1
self._action(
"PROJECT", f"{title} ({ptype}) [{self.state.projects_created}]"
)
await asyncio.sleep(2)
return True
return False
async def _report_issue(self) -> bool:
b = self.b
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-issue-modal"):
return False
topic = random.choice(
[
"UI glitch",
"Broken link",
"Performance issue",
"Auth problem",
"Formatting error",
]
)
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_issue, topic)
if not generated:
self._log("Report issue: generation failed")
return False
title, desc = generated
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-issue-modal .btn-primary"
)
if ok:
self.state.issues_filed += 1
self._action("ISSUE", f"{title[:50]} [{self.state.issues_filed}]")
await asyncio.sleep(2)
return True
return False
async def _update_profile(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/profile/{self.state.username}")
ok = await b.click("[data-edit-field='bio']")
if not ok:
return False
await b._idle(0.3, 0.8)
bio = await self._generate(self.llm.generate_bio)
if not bio:
self._log("Update profile: bio generation failed")
return False
bio = bio[:500]
await b.fill("textarea[name='bio']", bio)
await b._idle(0.3, 0.8)
filled = ["bio"]
fields = await self._generate(
self.llm.generate_profile_fields, self.state.username
)
if fields:
location, git_link, website = fields
for field_name, value in (
("location", location),
("git_link", git_link),
("website", website),
):
if not value:
continue
await b.click(f"[data-edit-field='{field_name}']")
await b._idle(0.2, 0.5)
if await b.fill(
f"input[name='{field_name}'], textarea[name='{field_name}']", value
):
filled.append(field_name)
await b._idle(0.2, 0.5)
ok = await b.click(
"form[action*='/profile/update'] button[type='submit'], button[type='submit']"
)
if ok:
self.state.profile_filled = True
self._save()
self._action("PROFILE", f"updated own profile ({', '.join(filled)})")
await asyncio.sleep(2)
return True
return False
async def _follow_user(self) -> bool:
b = self.b
curl = await b.url()
if "/profile/" not in curl:
return False
uname = curl.split("/profile/")[-1].split("?")[0]
if uname == self.state.username:
return False
form = b.page.locator("form[action*='/follow/'] button[type='submit']")
if await form.count() == 0:
return False
text = (await form.first.inner_text()).strip()
if text.lower() not in ("follow", "unfollow"):
return False
try:
await form.first.click(timeout=3000)
self._action(
"FOLLOW",
f"{'followed' if text.lower() == 'follow' else 'unfollowed'} @{uname}",
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
logger.debug("_follow_user failed: %s", e)
return False
def _classify_notification(self, text: str) -> bool:
tl = text.lower()
return (
f"@{self.state.username.lower()}" in tl
or "mentioned you" in tl
or "replied to your" in tl
or "replied to you" in tl
or "tagged you" in tl
)
async def _collect_notifications(self) -> list[dict]:
b = self.b
cards = b.page.locator(".notification-card")
try:
count = await cards.count()
except Exception:
count = 0
if count == 0:
return await self._collect_notifications_fallback()
items: list[dict] = []
seen_keys: set = set()
for i in range(min(count, 30)):
card = cards.nth(i)
text = ""
try:
body = card.locator(".notification-text").first
if await body.count() > 0:
text = (await body.inner_text() or "")[:600]
except Exception:
text = ""
if not text:
try:
text = (await card.inner_text() or "")[:600]
except Exception:
text = ""
href = ""
try:
link = card.locator("a.card-link").first
if await link.count() > 0:
href = (await link.get_attribute("href")) or ""
except Exception:
href = ""
unread = False
try:
cls = (await card.get_attribute("class")) or ""
unread = "unread" in cls.split()
except Exception:
unread = False
if not text and not href:
continue
key = hashlib.sha256(f"{href}|{text[:200]}".encode()).hexdigest()[:24]
if key in seen_keys:
continue
seen_keys.add(key)
items.append(
{
"key": key,
"href": href,
"text": text,
"is_mention": self._classify_notification(text),
"unread": unread,
}
)
return items
async def _collect_notifications_fallback(self) -> list[dict]:
b = self.b
loc = b.page.locator(".notification-item, .notif-item, [data-notification-id]")
try:
count = await loc.count()
except Exception:
count = 0
items: list[dict] = []
seen_keys: set = set()
for i in range(min(count, 30)):
el = loc.nth(i)
try:
text = (await el.inner_text() or "")[:600]
except Exception:
text = ""
href = ""
try:
inner = el.locator("a[href]").first
if await inner.count() > 0:
href = (await inner.get_attribute("href")) or ""
except Exception:
href = ""
if not text and not href:
continue
key = hashlib.sha256(f"{href}|{text[:200]}".encode()).hexdigest()[:24]
if key in seen_keys:
continue
seen_keys.add(key)
items.append(
{
"key": key,
"href": href,
"text": text,
"is_mention": self._classify_notification(text),
"unread": True,
}
)
return items
async def _check_notifications(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/notifications")
await b._idle(0.8, 1.8)
notifications = await self._collect_notifications()
if not notifications:
self._log("Notifications: none")
return True
unseen_mentions = [
n
for n in notifications
if n["is_mention"]
and n["unread"]
and n["key"] not in self.state.notifications_seen
]
self._log(
f"Notifications: {len(notifications)} ({len(unseen_mentions)} unseen mentions)"
)
self.state.mentions_received += len(unseen_mentions)
replied = 0
for n in unseen_mentions:
if self._session_mention_replies >= MENTION_REPLIES_PER_SESSION:
self._log(
f"Mention reply cap reached ({MENTION_REPLIES_PER_SESSION}/session), leaving the rest"
)
break
ok = await self._respond_to_mention(n["href"], n["text"])
if ok:
self.state.mentions_replied += 1
self._session_mention_replies += 1
replied += 1
self.state.notifications_seen.append(n["key"])
if len(self.state.notifications_seen) > 500:
self.state.notifications_seen = self.state.notifications_seen[-250:]
await b._idle(2.0, 5.0)
if replied == 0:
remaining = [
n
for n in notifications
if n["key"] not in self.state.notifications_seen
]
if remaining:
pick = random.choice(remaining)
target = pick["href"]
if target and not target.startswith("http"):
target = self.base_url + (
target if target.startswith("/") else "/" + target
)
if target:
try:
await b.goto(target)
self._log(f"Notifications: opened {target[-60:]}")
except Exception as e:
logger.debug("notification goto failed: %s", e)
self.state.notifications_seen.append(pick["key"])
try:
mark = b.page.locator(
"button:has-text('Mark all read'), form[action*='/mark-all-read'] button"
)
if await mark.count() > 0:
await mark.first.click(timeout=3000)
await b._idle(0.5, 1.5)
except Exception as e:
logger.debug("mark-read failed: %s", e)
return True
async def _respond_to_mention(self, href: str, snippet: str) -> bool:
b = self.b
target = href
if target and not target.startswith("http"):
target = self.base_url + (href if href.startswith("/") else "/" + href)
if not target:
return False
try:
await b.goto(target)
except Exception as e:
logger.debug("mention goto failed: %s", e)
return False
await b._idle(2.0, 4.0)
curl = await b.url()
if not any(k in curl for k in ("/posts/", "/gists/", "/projects/", "/news/")):
self._log("Mention target has no comment thread, skipping")
return False
thread_id = self._thread_id(curl)
if (
thread_id
and self.state.thread_reply_counts.get(thread_id, 0) >= MAX_THREAD_REPLIES
):
self._log(
f"Conversation taper on {thread_id[:12]} ({MAX_THREAD_REPLIES} replies), letting it rest"
)
return False
needle = f"@{self.state.username}"
comment_loc = None
comment_selectors = [
f".comment:has-text('{needle}')",
f"li.comment:has-text('{needle}')",
f".comment-item:has-text('{needle}')",
f"article.comment:has-text('{needle}')",
]
for sel in comment_selectors:
try:
loc = b.page.locator(sel).first
if await loc.count() > 0:
comment_loc = loc
break
except Exception:
continue
parent_text = ""
mentioner = ""
if comment_loc is not None:
try:
parent_text = (await comment_loc.inner_text() or "")[:1000]
except Exception:
parent_text = ""
try:
author_link = comment_loc.locator("a[href*='/profile/']").first
if await author_link.count() > 0:
ah = await author_link.get_attribute("href") or ""
if "/profile/" in ah:
mentioner = (
ah.split("/profile/")[-1].split("?")[0].split("/")[0]
)
except Exception:
mentioner = ""
try:
await comment_loc.scroll_into_view_if_needed(timeout=2000)
await b._idle(0.3, 0.9)
except Exception as e:
logger.debug("scroll mention failed: %s", e)
try:
reply_btn = comment_loc.locator(
".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')"
).first
if await reply_btn.count() > 0:
await reply_btn.click(timeout=3000)
await b._idle(0.5, 1.5)
except Exception as e:
logger.debug("reply-btn click failed: %s", e)
try:
post_body = (
await b.text(".post-content")
or await b.text(".gist-detail-desc")
or await b.text(".news-detail-content")
or await b.text(".news-detail-desc")
or await b.text(".project-detail-desc")
or await b.text(".rendered-content")
or await b.text("article")
or snippet
)
except Exception:
post_body = snippet
reply = await self._generate(
self.llm.generate_comment,
post_body[:1500],
self.state.persona,
mentioner,
parent_text,
)
if not reply:
self._log("Mention reply: generation failed/empty")
return False
if mentioner and f"@{mentioner.lower()}" not in reply.lower():
reply = f"@{mentioner} {reply}"
reply = reply[:2000]
textarea_sels = [
".comment .reply-form textarea[name='content']",
".reply-form textarea[name='content']",
"form.comment-form textarea[name='content']",
"textarea#comment-content",
"textarea.emoji-picker-target",
"textarea[name='content']",
]
typed = False
for sel in textarea_sels:
if await self._type_like_human(sel, reply):
typed = True
break
if not typed:
self._log("Mention reply: no textarea found")
return False
await b._idle(0.5, 1.5)
submit_sels = [
".reply-form button[type='submit']",
"form.comment-form button[type='submit']",
"button.btn-primary:has-text('Reply')",
"button.btn-primary:has-text('Post')",
"form[action*='/comments/create'] button[type='submit']",
".comment-form button[type='submit']",
]
for sel in submit_sels:
if await b.click(sel):
self.state.comments_posted += 1
self.state.replies_to_own_posts += 1
if thread_id:
self.state.thread_reply_counts[thread_id] = (
self.state.thread_reply_counts.get(thread_id, 0) + 1
)
if len(self.state.thread_reply_counts) > 500:
recent = list(self.state.thread_reply_counts.items())[-250:]
self.state.thread_reply_counts = dict(recent)
self._action("MENTION", f"replied to @{mentioner or '?'}: {reply[:60]}")
await asyncio.sleep(2)
return True
self._log("Mention reply: submit failed")
return False
async def _check_messages(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/messages")
await b._idle()
conv = b.page.locator(
".messages-conversations a, .conversation-item, .message-item, .messages-list a"
)
if await conv.count() == 0:
self._log("Messages: no conversations")
return True
try:
await conv.first.click(timeout=3000)
await b._idle(1.0, 2.0)
except Exception as e:
logger.debug("_check_messages open failed: %s", e)
return True
if random.random() < 0.7:
return await self._compose_message(reply=True)
self._log("Messages: read conversation")
return True
async def _spoke_last_in_thread(self) -> bool:
bubbles = self.b.page.locator(".messages-thread .message-bubble")
count = await bubbles.count()
if count == 0:
return False
try:
classes = await bubbles.nth(count - 1).get_attribute("class") or ""
except Exception as e:
logger.debug("_spoke_last_in_thread read failed: %s", e)
return False
return "mine" in classes.split()
async def _compose_message(self, reply: bool = False) -> bool:
b = self.b
box = b.page.locator(
"form[action*='/messages/send'] input[name='content'], "
"input[name='content'][placeholder*='message'], "
"form[action*='/messages/send'] textarea[name='content']"
)
if await box.count() == 0:
self._log("Message: compose box not found")
return False
if await self._spoke_last_in_thread():
self._log("Message: I spoke last; waiting for a reply (dialog, not monologue)")
return False
context = ""
try:
context = (
await b.text(".messages-thread") or await b.text(".message-list") or ""
)[:400]
except Exception:
context = ""
text = await self._generate(self.llm.generate_dm, self.state.persona, context)
if not text:
return False
text = text[:500]
if not await self._type_like_human(
"form[action*='/messages/send'] input[name='content'], input[name='content'], "
"form[action*='/messages/send'] textarea[name='content']",
text,
):
return False
await b._idle(0.4, 1.0)
if await b.click(
"button.messages-send-btn, form[action*='/messages/send'] button[type='submit']"
):
self.state.messages_sent += 1
self._action(
"MESSAGE",
f"{'replied' if reply else 'sent'} [{self.state.messages_sent}]: {text[:50]}",
)
await asyncio.sleep(2)
return True
return False
async def _send_message(self) -> bool:
b = self.b
curl = await b.url()
if "/profile/" not in curl:
return False
btn = b.page.locator("a[href*='with_uid=']").first
if await btn.count() == 0:
return False
href = await btn.get_attribute("href") or ""
if "with_uid=" not in href:
return False
try:
await b.goto((self.base_url + href) if href.startswith("/") else href)
await b._idle(1.0, 2.0)
except Exception as e:
logger.debug("_send_message goto failed: %s", e)
return False
return await self._compose_message(reply=False)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,52 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action, Catalog
from .admin import ADMIN_ACTIONS
from .auth import AUTH_ACTIONS
from .comments import COMMENTS_ACTIONS
from .dbapi import DBAPI_ACTIONS
from .engagement import ENGAGEMENT_ACTIONS
from .game import GAME_ACTIONS
from .gateway import GATEWAY_ACTIONS
from .gists import GIST_ACTIONS
from .issues import ISSUE_ACTIONS
from .jobs import JOB_ACTIONS
from .messages import MESSAGE_ACTIONS
from .news import NEWS_ACTIONS
from .notifications import NOTIFICATION_ACTIONS
from .posts import POSTS_ACTIONS
from .profile import PROFILE_ACTIONS
from .project_files import PROJECT_FILE_ACTIONS
from .projects import PROJECTS_ACTIONS
from .social import SOCIAL_ACTIONS
from .tools import TOOLS_ACTIONS
from .uploads import UPLOAD_ACTIONS
ACTIONS: tuple[Action, ...] = (
AUTH_ACTIONS
+ POSTS_ACTIONS
+ COMMENTS_ACTIONS
+ PROJECTS_ACTIONS
+ PROJECT_FILE_ACTIONS
+ JOB_ACTIONS
+ TOOLS_ACTIONS
+ PROFILE_ACTIONS
+ MESSAGE_ACTIONS
+ NOTIFICATION_ACTIONS
+ ENGAGEMENT_ACTIONS
+ SOCIAL_ACTIONS
+ ISSUE_ACTIONS
+ GIST_ACTIONS
+ NEWS_ACTIONS
+ UPLOAD_ACTIONS
+ ADMIN_ACTIONS
+ DBAPI_ACTIONS
+ GATEWAY_ACTIONS
+ GAME_ACTIONS
)
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
__all__ = ["ACTIONS", "PLATFORM_CATALOG"]

View File

@ -0,0 +1,41 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Param
def path(name: str, description: str, required: bool = True) -> Param:
return Param(name=name, location="path", description=description, required=required)
def query(name: str, description: str, required: bool = False) -> Param:
return Param(
name=name, location="query", description=description, required=required
)
def body(name: str, description: str, required: bool = False) -> Param:
return Param(name=name, location="body", description=description, required=required)
def upload(name: str, description: str) -> Param:
return Param(name=name, location="file", description=description, required=True)
def confirm() -> Param:
return Param(
name="confirm",
location="body",
description=(
"Set to true ONLY after the user has explicitly confirmed this irreversible action. "
"Leave it unset on the first call: the tool refuses and tells you to confirm with the "
"user, and only a repeat call with confirm=true actually performs it."
),
required=False,
type="boolean",
)
ATTACHMENTS = "Comma separated attachment uids returned by upload_file."
TARGET_TYPE = "Target type, e.g. post, comment, project, gist."

View File

@ -0,0 +1,425 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action
from ._shared import body, confirm, path, query
ADMIN_ACTIONS: tuple[Action, ...] = (
Action(
name="admin_overview",
method="GET",
path="/admin",
summary="View the admin overview",
requires_admin=True,
),
Action(
name="site_analytics",
method="GET",
path="/admin/analytics",
summary="Site-wide aggregate analytics in one call (admin only)",
description=(
"Returns JSON: total members, active users in the last 24h/7d/30d, signed_in_now, "
"new signups (24h/7d/30d), content totals (posts, comments, gists, projects, "
"news), and top authors. Use this for any 'how many'/'how active'/count question "
"instead of paging through admin_list_users. signed_in_now counts members holding an "
"unexpired session (logged in within the session lifetime, default 7-30 days), NOT a "
"real-time online count - see signed_in_definition in the response and never report it "
"as 'currently online' or 'logged in right now'."
),
params=(query("top_n", "How many top authors to include (1-50)."),),
requires_admin=True,
),
Action(
name="ai_usage",
method="GET",
path="/admin/ai-usage/data",
summary="AI gateway usage, cost, latency, and reliability metrics (admin only)",
description=(
"Returns JSON for a bounded window: request volume and throughput, token usage with "
"averages and percentiles, latency (upstream, gateway overhead, queue wait, connect), "
"error rates by category, cost in USD (per model, per caller, input vs output, projected "
"monthly burn, caching savings), caller behavior, and an hourly breakdown. Use this for "
"any question about AI spend, token consumption, gateway performance, or errors."
),
params=(
query("hours", "Lookback window in hours (1-168, default 48)."),
query("top_n", "How many rows in each top-N breakdown (default 10)."),
),
requires_admin=True,
),
Action(
name="audit_log",
method="GET",
path="/admin/audit-log",
summary="Query the platform audit trail with filters (admin only)",
description=(
"Returns JSON: a paginated, newest-first list of audit events (every state-changing "
"action on the platform, with actor, origin, target, old/new value, and result), plus "
"pagination, the active filters, and an 'options' object listing every available value "
"for each filter (category, event_key, actor_role, origin, result) so you can both "
"filter and discover valid filter values from a single call. Use this for any audit, "
"history, 'who did X', 'what changed', or 'show denied/failed actions' question. Filter "
"by event_key (exact), category, actor_role, actor_uid, origin (web, api, devii, cli, "
"service, scheduler), or result (success, failure, denied); narrow by date_from/date_to "
"(ISO date bounds, inclusive) or free-text q over summary, event key, and target."
),
params=(
query("page", "Page number (1-based)."),
query("event_key", "Filter by exact event key (e.g. admin.setting.update)."),
query("category", "Filter by category (auth, content, admin, container, ...)."),
query("actor_role", "Filter by actor role at action time."),
query("actor_uid", "Filter by acting user uid."),
query("origin", "Filter by origin (web, api, devii, cli, service, scheduler)."),
query("result", "Filter by result (success, failure, denied)."),
query("q", "Free-text search over summary, event key, and target."),
query("date_from", "ISO date lower bound (inclusive)."),
query("date_to", "ISO date upper bound (inclusive)."),
),
requires_admin=True,
),
Action(
name="audit_event",
method="GET",
path="/admin/audit-log/{uid}",
summary="Get one audit event with its related-object links (admin only)",
description=(
"Returns JSON: the full audit event row plus every related-object link (actor, target, "
"parent, project, instance, setting, ...). Use after audit_log to inspect a single event "
"in detail."
),
params=(path("uid", "Audit event uid."),),
requires_admin=True,
),
Action(
name="bot_monitor",
method="GET",
path="/admin/bots/data",
summary="Live screenshot monitor of every running bot persona (admin only)",
description=(
"Returns JSON: one entry per running bot slot with its username, persona, current "
"action and status text, last page url, frame age in seconds, whether it is active, "
"and a frame_url to the latest low-quality screenshot. Use this to see what the bot "
"fleet is doing right now. Frames live only in the worker running the Bots service."
),
requires_admin=True,
),
Action(
name="admin_list_users",
method="GET",
path="/admin/users",
summary="List users for administration",
params=(query("page", "Page number."),),
requires_admin=True,
),
Action(
name="admin_set_user_role",
method="POST",
path="/admin/users/{uid}/role",
summary="Set a user's role",
params=(
path("uid", "User uid."),
body("role", "New role.", required=True),
),
requires_admin=True,
),
Action(
name="admin_set_user_password",
method="POST",
path="/admin/users/{uid}/password",
summary="Set a user's password",
params=(
path("uid", "User uid."),
body("password", "New password.", required=True),
),
requires_admin=True,
),
Action(
name="admin_toggle_user",
method="POST",
path="/admin/users/{uid}/toggle",
summary="Toggle a user's active state",
params=(path("uid", "User uid."),),
requires_admin=True,
),
Action(
name="admin_get_settings",
method="GET",
path="/admin/settings",
summary="View site settings",
requires_admin=True,
),
Action(
name="admin_save_settings",
method="POST",
path="/admin/settings",
summary="Save site settings",
params=(
body("site_name", "Site name."),
body("site_description", "Site description."),
body("site_tagline", "Site tagline."),
body("max_upload_size_mb", "Maximum upload size in megabytes."),
body("allowed_file_types", "Allowed file types."),
body("max_attachments_per_resource", "Maximum attachments per resource."),
body("rate_limit_per_minute", "Rate limit per minute."),
body("rate_limit_window_seconds", "Rate limit window in seconds."),
body("session_max_age_days", "Session maximum age in days."),
body("session_remember_days", "Remember-me duration in days."),
body("registration_open", "Whether registration is open."),
body("maintenance_mode", "Whether maintenance mode is on."),
body("maintenance_message", "Maintenance message."),
),
requires_admin=True,
),
Action(
name="admin_list_news",
method="GET",
path="/admin/news",
summary="List news for administration",
params=(query("page", "Page number."),),
requires_admin=True,
),
Action(
name="admin_toggle_news",
method="POST",
path="/admin/news/{uid}/toggle",
summary="Toggle a news article",
params=(path("uid", "News uid."),),
requires_admin=True,
),
Action(
name="admin_publish_news",
method="POST",
path="/admin/news/{uid}/publish",
summary="Publish a news article",
params=(path("uid", "News uid."),),
requires_admin=True,
),
Action(
name="admin_landing_news",
method="POST",
path="/admin/news/{uid}/landing",
summary="Set a news article as landing content",
params=(path("uid", "News uid."),),
requires_admin=True,
),
Action(
name="admin_delete_news",
method="POST",
path="/admin/news/{uid}/delete",
summary="Delete a news article (admin only). Soft delete, confirmation required",
params=(path("uid", "News uid."), confirm()),
requires_admin=True,
),
Action(
name="admin_list_services",
method="GET",
path="/admin/services",
summary="View managed services",
requires_admin=True,
),
Action(
name="admin_services_data",
method="GET",
path="/admin/services/data",
summary="Get live status, metrics, and log tail for every background service",
requires_admin=True,
),
Action(
name="admin_service_status",
method="GET",
path="/admin/services/{name}/data",
summary="Get live status, metrics, and log tail for one background service",
params=(path("name", "Service name (e.g. news, bots, openai)."),),
requires_admin=True,
),
Action(
name="admin_start_service",
method="POST",
path="/admin/services/{name}/start",
summary="Start a managed service",
params=(path("name", "Service name."),),
requires_admin=True,
),
Action(
name="admin_stop_service",
method="POST",
path="/admin/services/{name}/stop",
summary="Stop a managed service",
params=(path("name", "Service name."),),
requires_admin=True,
),
Action(
name="admin_run_service",
method="POST",
path="/admin/services/{name}/run",
summary="Run a managed service once",
params=(path("name", "Service name."),),
requires_admin=True,
),
Action(
name="admin_clear_service_logs",
method="POST",
path="/admin/services/{name}/clear-logs",
summary="Clear a managed service's logs",
params=(path("name", "Service name."),),
requires_admin=True,
),
Action(
name="admin_config_service",
method="POST",
path="/admin/services/{name}/config",
summary="Update a managed service's configuration",
params=(path("name", "Service name."),),
freeform_body=True,
requires_admin=True,
),
Action(
name="admin_user_ai_usage",
method="GET",
path="/admin/users/{uid}/ai-usage",
summary="Get a user's AI gateway usage (admin only)",
description="Returns per-user AI usage data including token counts, cost, and request volume for the given lookback window.",
params=(
path("uid", "User uid."),
query("hours", "Lookback window in hours (default 24)."),
),
requires_admin=True,
),
Action(
name="admin_reset_user_ai_quota",
method="POST",
path="/admin/users/{uid}/reset-ai-quota",
summary="Reset a user's AI quota (admin only)",
description="Clears the AI quota ledger for a specific user, allowing them to use AI features again.",
params=(path("uid", "User uid."), confirm()),
requires_admin=True,
),
Action(
name="admin_media_purge",
method="POST",
path="/admin/media/{uid}/purge",
summary="Permanently remove soft-deleted media (admin only)",
description="Permanently deletes a soft-deleted attachment from the database and filesystem.",
params=(path("uid", "Attachment uid to purge."), confirm()),
requires_admin=True,
),
Action(
name="admin_reset_guest_ai_quota",
method="POST",
path="/admin/ai-quota/reset-guests",
summary="Reset all guest AI quotas (admin only)",
description="Clears the AI quota ledger for all guest sessions.",
params=(confirm(),),
requires_admin=True,
),
Action(
name="admin_reset_all_ai_quota",
method="POST",
path="/admin/ai-quota/reset-all",
summary="Reset ALL AI quotas including member quotas (admin only)",
description="Clears the AI quota ledger for every user and guest. Use with caution.",
params=(confirm(),),
requires_admin=True,
),
Action(
name="backups_overview",
method="GET",
path="/admin/backups/data",
summary="Backup dashboard: storage usage, backups, and schedules (admin only)",
description=(
"Returns JSON: per-path storage usage (database, uploads, attachments, project files, "
"keys, zips, deepsearch, container workspaces, backups), total data-directory size, total "
"stored-backup size and count, disk usage (total/used/free/percent), every backup archive "
"(target, status, size, file count, sha256 checksum, created/completed time, download_url), "
"and every configured backup schedule. Use this for any question about backups, data "
"footprint, disk space, or what is scheduled."
),
requires_admin=True,
),
Action(
name="backup_run",
method="POST",
path="/admin/backups/run",
summary="Start a backup of a data target (admin only)",
description=(
"Enqueues an async backup job and returns its job uid and status_url. Target is one of: "
"database (consistent SQLite snapshot plus Devii databases), uploads (attachments and "
"project files), keys (VAPID keys and config), or full (database, uploads, and keys in one "
"archive). Poll backup_status with the returned uid until status is done, then read the "
"download_url. The job runs off the request path and never blocks the server."
),
params=(
body("target", "One of database, uploads, keys, full.", required=True),
),
requires_admin=True,
),
Action(
name="backup_status",
method="GET",
path="/admin/backups/jobs/{uid}",
summary="Check the status of a backup job (admin only)",
description=(
"Returns JSON for one backup job: status (pending, running, done, failed), target, "
"backup_uid, download_url (when done), sha256, archive size, file count, and timestamps. "
"Poll this after backup_run."
),
params=(path("uid", "Backup job uid returned by backup_run."),),
requires_admin=True,
),
Action(
name="backup_delete",
method="POST",
path="/admin/backups/{uid}/delete",
summary="Permanently delete a backup archive (admin only)",
description=(
"Removes a backup archive file and its record. This is irreversible and reclaims disk "
"space. The uid is a backup uid (from backups_overview), not a job uid."
),
params=(path("uid", "Backup uid to delete."), confirm()),
requires_admin=True,
),
Action(
name="backup_schedule_create",
method="POST",
path="/admin/backups/schedules/create",
summary="Create a recurring backup schedule (admin only)",
description=(
"Creates a schedule that fires backups automatically. kind is 'interval' (use every_seconds, "
"minimum 60) or 'cron' (use a 5-field cron expression in 'minute hour dom month dow'). "
"keep_last rotates older backups of this schedule, keeping only the newest N (0 keeps all). "
"target is one of database, uploads, keys, full."
),
params=(
body("name", "Human-readable schedule name.", required=True),
body("target", "One of database, uploads, keys, full.", required=True),
body("kind", "interval or cron.", required=True),
body("every_seconds", "Seconds between runs when kind=interval (min 60)."),
body("cron", "Cron expression when kind=cron, e.g. '0 3 * * *'."),
body("keep_last", "Keep only the newest N backups of this schedule (0 = all)."),
),
requires_admin=True,
),
Action(
name="backup_schedule_delete",
method="POST",
path="/admin/backups/schedules/{uid}/delete",
summary="Delete a backup schedule (admin only)",
description=(
"Removes a backup schedule so it stops firing. Existing backup archives are kept; only the "
"schedule is deleted."
),
params=(path("uid", "Backup schedule uid."), confirm()),
requires_admin=True,
),
Action(
name="restore_media",
method="POST",
path="/media/{uid}/restore",
summary="Restore a soft-deleted media attachment (admin only)",
description="Restores a previously soft-deleted attachment, making it visible again.",
params=(path("uid", "Attachment uid to restore."),),
requires_admin=True,
),
)

View File

@ -0,0 +1,72 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action
from ._shared import body, path
AUTH_ACTIONS: tuple[Action, ...] = (
Action(
name="auth_status",
method="GET",
path="",
summary="Report whether the current session is authenticated",
handler="status",
requires_auth=False,
),
Action(
name="login",
method="POST",
path="/auth/login",
summary="Authenticate with email and password and start a session",
handler="login",
requires_auth=False,
params=(
body("email", "Account email address.", required=True),
body("password", "Account password.", required=True),
body("remember_me", "Keep the session alive longer ('on' or '')."),
),
),
Action(
name="logout",
method="GET",
path="/auth/logout",
summary="End the current session",
handler="logout",
requires_auth=True,
),
Action(
name="signup",
method="POST",
path="/auth/signup",
summary="Create a new account",
requires_auth=False,
params=(
body("username", "Desired username.", required=True),
body("email", "Email address.", required=True),
body("password", "Password (minimum six characters).", required=True),
body("confirm_password", "Password confirmation.", required=True),
),
),
Action(
name="forgot_password",
method="POST",
path="/auth/forgot-password",
summary="Request a password reset email",
requires_auth=False,
params=(body("email", "Account email address.", required=True),),
),
Action(
name="reset_password",
method="POST",
path="/auth/reset-password/{token}",
summary="Reset a password using a reset token",
requires_auth=False,
params=(
path("token", "Reset token from the email link."),
body("password", "New password.", required=True),
body("confirm_password", "New password confirmation.", required=True),
),
),
)

View File

@ -0,0 +1,41 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action
from ._shared import ATTACHMENTS, TARGET_TYPE, body, confirm, path
COMMENTS_ACTIONS: tuple[Action, ...] = (
Action(
name="create_comment",
method="POST",
path="/comments/create",
summary="Create a comment on a post or other target",
params=(
body("content", "Comment body.", required=True),
body("post_uid", "Uid of the post being commented on."),
body("target_uid", "Uid of the target when not a post."),
body("target_type", TARGET_TYPE),
body("parent_uid", "Parent comment uid for replies."),
body("attachment_uids", ATTACHMENTS),
),
),
Action(
name="edit_comment",
method="POST",
path="/comments/edit/{comment_uid}",
summary="Edit the body of one of your own comments",
params=(
path("comment_uid", "Uid of the comment."),
body("content", "New comment body, 3-1000 characters.", required=True),
),
),
Action(
name="delete_comment",
method="POST",
path="/comments/delete/{comment_uid}",
summary="Delete a comment (your own, or any comment when you are an administrator). Soft delete, confirmation required",
params=(path("comment_uid", "Uid of the comment."), confirm()),
),
)

View File

@ -0,0 +1,127 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action, Param
from ._shared import body, path, query
DBAPI_ACTIONS: tuple[Action, ...] = (
Action(
name="db_list_tables",
method="GET",
path="/dbapi/tables",
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 "
"querying or designing a query."
),
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 (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 (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."
),
params=(
path("table", "Table name."),
query("limit", "Maximum rows (1-500, default 25)."),
query("search", "Free-text search over common text columns."),
query("before", "Keyset cursor: return rows older than this created_at/id value."),
Param(
name="include_deleted",
location="query",
description="Include soft-deleted rows.",
required=False,
type="boolean",
),
),
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 (primary administrator only)",
description="Returns a single row where key column equals value (key is usually 'uid').",
params=(
path("table", "Table name."),
path("key", "Key column to match (usually 'uid')."),
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 (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 "
"read-only and cannot change data in any way. The response may include a 'suspicious' "
"list (e.g. a SELECT with no WHERE/JOIN/LIMIT that scans a whole table); when present, "
"surface that warning to the user before trusting the results."
),
params=(
body("sql", "A single SELECT statement.", required=True),
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 (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 "
"false. By default it only returns the SQL; pass execute=true to also run it read-only "
"and return rows. Show the user the SQL and any 'suspicious' notes."
),
params=(
body("question", "The natural-language request.", required=True),
body("table", "Target table the question is about.", required=True),
Param(
name="apply_soft_delete",
location="body",
description="Add deleted_at IS NULL for soft-delete tables (default true).",
required=False,
type="boolean",
),
body("dialect", "Optional target SQL dialect (default sqlite)."),
Param(
name="execute",
location="body",
description="Also run the validated query read-only and return rows.",
required=False,
type="boolean",
),
),
requires_admin=True,
requires_primary_admin=True,
read_only=True,
),
)

View File

@ -0,0 +1,80 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action
from ._shared import TARGET_TYPE, body, path, query
ENGAGEMENT_ACTIONS: tuple[Action, ...] = (
Action(
name="vote",
method="POST",
path="/votes/{target_type}/{target_uid}",
summary="Cast or toggle a vote on a target",
description="Re-sending the same value removes the vote. Returns the net/up/down tally.",
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path(
"target_uid",
"Uid of the target, copied from a listing response; do not invent it.",
),
body(
"value",
"Vote value: 1 to upvote, -1 to downvote (re-send to remove).",
required=True,
),
),
),
Action(
name="react",
method="POST",
path="/reactions/{target_type}/{target_uid}",
summary="Toggle an emoji reaction on a target",
description="Re-sending the same emoji removes it. Returns reaction counts.",
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path(
"target_uid",
"Uid of the target, copied from a listing response; do not invent it.",
),
body("emoji", "One of the allowed reaction emoji.", required=True),
),
),
Action(
name="list_bookmarks",
method="GET",
path="/bookmarks/saved",
summary="List saved bookmarks",
params=(query("before", "Pagination cursor."),),
),
Action(
name="toggle_bookmark",
method="POST",
path="/bookmarks/{target_type}/{target_uid}",
summary="Toggle a bookmark on a target",
description="Re-sending removes the bookmark. Returns {saved: true|false}.",
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path(
"target_uid",
"Uid of the target, copied from a listing response; do not invent it.",
),
),
),
Action(
name="vote_poll",
method="POST",
path="/polls/{poll_uid}/vote",
summary="Vote in a poll",
description="Casts or changes your vote on a poll option. Returns the option tallies.",
ajax=True,
params=(
path("poll_uid", "Poll uid."),
body("option_uid", "Chosen option uid.", required=True),
),
),
)

Some files were not shown because too many files have changed in this diff Show More