feat: add soft delete and media tab for user attachments with admin restore

This commit is contained in:
2026-06-11 18:52:56 +00:00
parent c74228bc6c
commit 3862958fae
40 changed files with 1549 additions and 81 deletions
+321 -6
View File
@@ -59,6 +59,8 @@ class DevPlaceBot:
self._pause_min = min(cfg.action_pause_min, cfg.action_pause_max)
self._pause_max = max(cfg.action_pause_min, cfg.action_pause_max)
self._break_scale = max(0.1, cfg.break_scale)
self._ai_decisions = cfg.ai_decisions
self._decision_temperature = cfg.decision_temperature
self.faker = Faker()
self.b = BotBrowser(headless=cfg.headless)
self._post_cache: list[tuple[str, str, str, str, str]] = []
@@ -66,6 +68,9 @@ class DevPlaceBot:
self._bug_cache: list[tuple[str, str]] = []
self._session_start_cost: float = 0.0
self._session_start_calls: int = 0
self._session_history: list[str] = []
self._session_energy: float = 0.5
self._session_stop_after: int = 0
def _identity(self) -> str:
return f"{self.state.username or '?'}/{self.state.persona}"
@@ -355,6 +360,294 @@ class DevPlaceBot:
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 _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
return {
"page": page,
"url": curl,
"visible": await self._page_summary(page),
"own_post": own_post,
"mentioned": mentioned,
"did_post": did_post,
"follows": follows,
"gists": gists,
"can_project": can_project,
"can_post": can_post,
"can_message": can_message,
}
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 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")
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,
"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))
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']"
@@ -2245,6 +2538,10 @@ class DevPlaceBot:
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
use_ai = False
try:
await b.launch()
@@ -2263,6 +2560,10 @@ class DevPlaceBot:
await asyncio.sleep(60)
continue
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()}")
@@ -2305,7 +2606,7 @@ class DevPlaceBot:
session_projects,
session_follows,
session_gists,
) = await self._cycle(
) = await (self._cycle_ai if use_ai else self._cycle)(
mood=mood,
session_posted=session_posted,
session_projects=session_projects,
@@ -2326,12 +2627,23 @@ class DevPlaceBot:
self._save()
crash_streak = 0
fatigue = await self._fatigue_check(session_actions)
if fatigue:
self._log(f"Fatigue ({fatigue}): ending session")
break
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)
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()}"
)
@@ -2368,6 +2680,9 @@ class DevPlaceBot:
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")
+3
View File
@@ -23,6 +23,9 @@ ACTION_PAUSE_MIN_SECONDS = 5
ACTION_PAUSE_MAX_SECONDS = 30
BREAK_SCALE_DEFAULT = 1.0
AI_DECISIONS_DEFAULT = False
DECISION_TEMPERATURE_DEFAULT = 0.4
PERSONAS = [
"enthusiastic_junior",
"grumpy_senior",
+184 -2
View File
@@ -5,12 +5,14 @@ import re
import time
import urllib.error
import urllib.request
from typing import Any
from devplacepy.services.bot.config import (
GIST_LANGUAGES,
GIST_MIN_LINES,
PERSONA_GIST_FLAVOR,
PERSONA_LANGUAGES,
SEARCH_TERMS,
TRIVIAL_GIST_TERMS,
)
@@ -40,7 +42,7 @@ class LLMClient:
self.total_in_tokens = 0
self.total_out_tokens = 0
def _call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
def _raw_call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
for attempt in range(3):
try:
payload = {
@@ -80,7 +82,7 @@ class LLMClient:
self.total_calls += 1
self.total_in_tokens += in_tokens
self.total_out_tokens += out_tokens
return self.clean(result["choices"][0]["message"]["content"])
return result["choices"][0]["message"]["content"]
except (
urllib.error.HTTPError,
urllib.error.URLError,
@@ -93,6 +95,9 @@ class LLMClient:
time.sleep(2**attempt)
return ""
def _call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
return self.clean(self._raw_call(system, prompt, temperature))
@staticmethod
def clean(text: str, preserve_md: bool = False) -> str:
if not preserve_md:
@@ -341,6 +346,183 @@ class LLMClient:
return True, "ok"
return False, verdict.strip()[:120] or "judge rejected"
@staticmethod
def _parse_json(text: str) -> dict:
text = LLMClient.strip_code_fences(text or "").strip()
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
text = text[start : end + 1]
parsed = json.loads(text)
if not isinstance(parsed, dict):
raise ValueError("expected a JSON object")
return parsed
@staticmethod
def _clamp01(value: Any, default: float) -> float:
try:
return max(0.0, min(1.0, float(value)))
except (TypeError, ValueError):
return default
@staticmethod
def _as_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
@staticmethod
def _as_terms(value: Any) -> list[str]:
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()][:8]
if isinstance(value, str) and value.strip():
return [part.strip() for part in value.split(",") if part.strip()][:8]
return []
def _normalize_identity(self, data: dict, archetype: str) -> dict:
interests = self._as_terms(data.get("interests")) or list(
SEARCH_TERMS.get(archetype, [])
)
return {
"archetype": archetype,
"name": str(data.get("name", "")).strip()[:60],
"backstory": str(data.get("backstory", "")).strip()[:300],
"interests": interests,
"dislikes": self._as_terms(data.get("dislikes")),
"temperament": str(data.get("temperament", "")).strip()[:120],
"verbosity": self._clamp01(data.get("verbosity"), 0.5),
"contrarianness": self._clamp01(data.get("contrarianness"), 0.3),
"generosity": self._clamp01(data.get("generosity"), 0.5),
"curiosity": self._clamp01(data.get("curiosity"), 0.5),
"rhythm": str(data.get("rhythm", "")).strip()[:120],
}
def generate_identity(self, archetype: str) -> dict:
interests = ", ".join(SEARCH_TERMS.get(archetype, []))
system = (
"You invent a unique, believable individual developer for a social coding "
"platform, seeded from a broad archetype but distinct from anyone else who "
"shares it. Return ONLY a JSON object, no prose and no markdown fences, with "
"these keys: name (a short handle-like display name), backstory (one concrete "
"sentence), interests (array of 4 to 6 short topics), dislikes (array of 2 to 4 "
"short topics), temperament (a few words), verbosity (number 0.0 to 1.0), "
"contrarianness (number 0.0 to 1.0), generosity (number 0.0 to 1.0), curiosity "
"(number 0.0 to 1.0), rhythm (a few words on when and how they show up). Make it "
"specific and memorable, never generic. No em dashes."
)
prompt = (
f"Archetype: {archetype}\n"
f"Typical interests for this archetype: {interests}\n"
"Create the persona JSON:"
)
data = self._parse_json(self._raw_call(system, prompt, temperature=0.9))
return self._normalize_identity(data, archetype)
@staticmethod
def _identity_card(identity: dict) -> str:
lines = [
f"name: {identity.get('name', '')}",
f"archetype: {identity.get('archetype', '')}",
f"backstory: {identity.get('backstory', '')}",
f"interests: {', '.join(identity.get('interests', []))}",
f"dislikes: {', '.join(identity.get('dislikes', []))}",
f"temperament: {identity.get('temperament', '')}",
f"verbosity: {identity.get('verbosity', 0.5)}",
f"contrarianness: {identity.get('contrarianness', 0.3)}",
f"generosity: {identity.get('generosity', 0.5)}",
f"curiosity: {identity.get('curiosity', 0.5)}",
f"rhythm: {identity.get('rhythm', '')}",
]
return "\n".join(lines)
def _decision_system(self, identity: dict) -> str:
return (
"You are role-playing a single developer on a social coding platform. Stay in "
"character at all times.\n\n"
"IDENTITY\n"
f"{self._identity_card(identity)}\n\n"
"HOW TO DECIDE\n"
"- Choose the actions that THIS person, with these interests and this "
"temperament, would take on the page described. Behaviour must follow the "
"identity, not chance.\n"
"- A generous person votes and reacts readily; a contrarian one comments to push "
"back; a terse, low-verbosity one acts rarely and briefly; a curious one explores "
"and navigates. Let the numbers and interests drive the plan.\n"
"- Only choose actions whose name appears in the MENU. Never invent an action or "
"a target.\n"
"- Order the plan the way this person would actually act, and include only what "
"they would genuinely do (an empty plan is fine if nothing here interests them).\n"
"- energy is how engaged this person is right now, 0.0 (barely present) to 1.0 "
"(highly active), following their rhythm.\n"
"- stop_after is roughly how many more actions this whole visit warrants before "
"they would naturally drift away.\n"
"- Output ONLY the JSON object below. No prose, no markdown fences.\n\n"
"OUTPUT SCHEMA\n"
'{"plan":[{"action":"<menu action>","target":"<id or empty>",'
'"rationale":"<short>"}],"energy":<0.0-1.0>,"stop_after":<integer>}'
)
@staticmethod
def _decision_prompt(page_state: dict, menu: list[dict], history: list[str]) -> str:
menu_lines = "\n".join(f"- {item['action']}: {item['desc']}" for item in menu)
recent = "; ".join(history[-8:]) if history else "nothing yet this visit"
return (
f"PAGE: {page_state.get('page', 'unknown')}\n"
f"VISIBLE: {page_state.get('visible', '')}\n"
f"MENU:\n{menu_lines}\n"
f"RECENT (this visit): {recent}"
)
@staticmethod
def _filter_plan(plan: Any, allowed: set) -> list[dict]:
if not isinstance(plan, list):
return []
result = []
for entry in plan:
if not isinstance(entry, dict):
continue
action = str(entry.get("action", "")).strip()
if action not in allowed:
continue
result.append(
{
"action": action,
"target": str(entry.get("target", "")).strip()[:120],
"rationale": str(entry.get("rationale", "")).strip()[:200],
}
)
return result[:12]
def decide(
self,
identity: dict,
page_state: dict,
menu: list[dict],
history: list[str],
temperature: float = 0.4,
) -> dict:
allowed = {item["action"] for item in menu}
system = self._decision_system(identity)
base = self._decision_prompt(page_state, menu, history)
result = {"plan": [], "energy": 0.5, "stop_after": 0}
for attempt in range(2):
prompt = base
if attempt:
prompt += "\n\nReturn ONLY valid JSON matching the schema. No prose."
try:
data = self._parse_json(self._raw_call(system, prompt, temperature))
except (ValueError, KeyError, json.JSONDecodeError):
continue
result = {
"plan": self._filter_plan(data.get("plan"), allowed),
"energy": self._clamp01(data.get("energy"), 0.5),
"stop_after": self._as_int(data.get("stop_after"), 0),
}
if result["plan"]:
break
return result
def generate_bug(self, topic: str) -> tuple[str, str]:
text = self._call(
"Write a bug report. One line title. Then 2-3 sentences describing what happened and what should have happened. Like a real dev reporting a bug. No em dashes.",
+2
View File
@@ -22,3 +22,5 @@ class BotRuntimeConfig:
action_pause_min: int = config.ACTION_PAUSE_MIN_SECONDS
action_pause_max: int = config.ACTION_PAUSE_MAX_SECONDS
break_scale: float = config.BREAK_SCALE_DEFAULT
ai_decisions: bool = config.AI_DECISIONS_DEFAULT
decision_temperature: float = config.DECISION_TEMPERATURE_DEFAULT
+20
View File
@@ -167,6 +167,24 @@ class BotsService(BaseService):
help="Scales the between-session break. Below 1 makes the fleet more active (and costlier); above 1 calmer.",
group="Pacing",
),
ConfigField(
"bot_ai_decisions",
"AI-driven decisions",
type="bool",
default=config.AI_DECISIONS_DEFAULT,
help="Let each bot's persona decide every action via the LLM instead of fixed probabilities. Adds one decision call per page; falls back to the procedural cascade when off.",
group="Decisions",
),
ConfigField(
"bot_decision_temperature",
"Decision temperature",
type="float",
default=config.DECISION_TEMPERATURE_DEFAULT,
minimum=0.0,
maximum=2.0,
help="Sampling temperature for the per-page decision call. Low values keep structured output stable; variety comes from the identity, not noise.",
group="Decisions",
),
]
def __init__(self):
@@ -249,6 +267,8 @@ class BotsService(BaseService):
action_pause_min=cfg["bot_action_pause_min_seconds"],
action_pause_max=cfg["bot_action_pause_max_seconds"],
break_scale=cfg["bot_break_scale"],
ai_decisions=cfg["bot_ai_decisions"],
decision_temperature=cfg["bot_decision_temperature"],
)
bot = DevPlaceBot(rc, on_event=self.log)
task = asyncio.create_task(self._run_slot(slot, bot, cfg["bot_max_actions"]))
+1
View File
@@ -14,6 +14,7 @@ class BotState:
password: str = ""
uid: str = ""
persona: str = ""
identity: dict = field(default_factory=dict)
log: list[str] = field(default_factory=list)
known_users: list[str] = field(default_factory=list)
known_posts: list[str] = field(default_factory=list)
@@ -734,6 +734,30 @@ ACTIONS: tuple[Action, ...] = (
query("page", "Page number, 25 per page."),
),
),
Action(
name="list_media",
method="GET",
path="/profile/{username}?tab=media",
summary="List a user's uploaded media, newest first",
requires_auth=False,
params=(
path("username", "Username whose media to list."),
query("page", "Page number, 24 per page."),
),
),
Action(
name="delete_media",
method="POST",
path="/media/{uid}/delete",
summary="Delete one of your uploaded media attachments",
description=(
"Removes the attachment from the user's Media tab and from any post, project, or "
"gist where it was attached. Show the user the exact media, get explicit "
"confirmation, then call again with confirm=true."
),
requires_auth=True,
params=(path("uid", "Attachment uid to delete."),),
),
Action(
name="view_leaderboard",
method="GET",
@@ -38,6 +38,7 @@ CONFIRM_REQUIRED = {
"customize_reset",
"project_delete_file",
"delete_project",
"delete_media",
}
DESTRUCTIVE_COMMAND = re.compile(
@@ -98,6 +99,12 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
"Deleting a project permanently removes the project and ALL of its files. Ask the user "
"to confirm this explicitly first, then call again with confirm=true."
)
if name == "delete_media":
return ToolInputError(
"Deleting media removes it from every display surface, including its parent post or "
"project. Show the user the exact media, get explicit confirmation, then call again "
"with confirm=true."
)
if (
name == "container_instance_action"
and str(arguments.get("action", "")).strip().lower() == "delete"