From 574b076f5d3e97575a8e4de3662f732a26aa37b4 Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 4 Jul 2026 19:19:39 +0000 Subject: [PATCH] refactor: split bot.py into mixins (ref.md 3.3) Co-Authored-By: Claude Opus 4.8 (1M context) --- devplacepy/services/bot/agent.py | 238 +++ devplacepy/services/bot/auth.py | 123 ++ devplacepy/services/bot/bot.py | 2919 +--------------------------- devplacepy/services/bot/browse.py | 140 ++ devplacepy/services/bot/engage.py | 705 +++++++ devplacepy/services/bot/helpers.py | 157 ++ devplacepy/services/bot/human.py | 178 ++ devplacepy/services/bot/loop.py | 514 +++++ devplacepy/services/bot/posting.py | 345 ++++ devplacepy/services/bot/social.py | 624 ++++++ 10 files changed, 3044 insertions(+), 2899 deletions(-) create mode 100644 devplacepy/services/bot/agent.py create mode 100644 devplacepy/services/bot/auth.py create mode 100644 devplacepy/services/bot/browse.py create mode 100644 devplacepy/services/bot/engage.py create mode 100644 devplacepy/services/bot/helpers.py create mode 100644 devplacepy/services/bot/human.py create mode 100644 devplacepy/services/bot/loop.py create mode 100644 devplacepy/services/bot/posting.py create mode 100644 devplacepy/services/bot/social.py diff --git a/devplacepy/services/bot/agent.py b/devplacepy/services/bot/agent.py new file mode 100644 index 00000000..4cde2f0b --- /dev/null +++ b/devplacepy/services/bot/agent.py @@ -0,0 +1,238 @@ +# retoor + +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)) diff --git a/devplacepy/services/bot/auth.py b/devplacepy/services/bot/auth.py new file mode 100644 index 00000000..9af4c165 --- /dev/null +++ b/devplacepy/services/bot/auth.py @@ -0,0 +1,123 @@ +# retoor + +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 diff --git a/devplacepy/services/bot/bot.py b/devplacepy/services/bot/bot.py index 93e633ad..2d8c8458 100644 --- a/devplacepy/services/bot/bot.py +++ b/devplacepy/services/bot/bot.py @@ -41,11 +41,30 @@ from devplacepy.services.bot.news_fetcher import NewsFetcher from devplacepy.services.bot.registry import ArticleRegistry from devplacepy.services.bot.runtime import BotRuntimeConfig from devplacepy.services.bot.state import BotState +from devplacepy.services.bot.agent import BotAgentMixin +from devplacepy.services.bot.auth import BotAuthMixin +from devplacepy.services.bot.browse import BotBrowseMixin +from devplacepy.services.bot.engage import BotEngageMixin +from devplacepy.services.bot.helpers import BotHelpersMixin +from devplacepy.services.bot.human import BotHumanMixin +from devplacepy.services.bot.loop import BotLoopMixin +from devplacepy.services.bot.posting import BotPostingMixin +from devplacepy.services.bot.social import BotSocialMixin logger = logging.getLogger(__name__) -class DevPlaceBot: +class DevPlaceBot( + BotHelpersMixin, + BotAuthMixin, + BotHumanMixin, + BotAgentMixin, + BotEngageMixin, + BotPostingMixin, + BotSocialMixin, + BotBrowseMixin, + BotLoopMixin, +): def __init__(self, cfg: BotRuntimeConfig, on_event=None, slot: int = 0): self.cfg = cfg self.base_url = cfg.base_url @@ -99,2901 +118,3 @@ class DevPlaceBot: 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}" - - 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")) - - 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 - - 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(), - } - - 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)) - - 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 - - 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 - - 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) - - 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 - - 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") diff --git a/devplacepy/services/bot/browse.py b/devplacepy/services/bot/browse.py new file mode 100644 index 00000000..32beea34 --- /dev/null +++ b/devplacepy/services/bot/browse.py @@ -0,0 +1,140 @@ +# retoor + +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 diff --git a/devplacepy/services/bot/engage.py b/devplacepy/services/bot/engage.py new file mode 100644 index 00000000..92804395 --- /dev/null +++ b/devplacepy/services/bot/engage.py @@ -0,0 +1,705 @@ +# retoor + +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 diff --git a/devplacepy/services/bot/helpers.py b/devplacepy/services/bot/helpers.py new file mode 100644 index 00000000..fb5bc067 --- /dev/null +++ b/devplacepy/services/bot/helpers.py @@ -0,0 +1,157 @@ +# retoor + +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")) diff --git a/devplacepy/services/bot/human.py b/devplacepy/services/bot/human.py new file mode 100644 index 00000000..04521038 --- /dev/null +++ b/devplacepy/services/bot/human.py @@ -0,0 +1,178 @@ +# retoor + +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(), + } diff --git a/devplacepy/services/bot/loop.py b/devplacepy/services/bot/loop.py new file mode 100644 index 00000000..212a84d3 --- /dev/null +++ b/devplacepy/services/bot/loop.py @@ -0,0 +1,514 @@ +# retoor + +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") diff --git a/devplacepy/services/bot/posting.py b/devplacepy/services/bot/posting.py new file mode 100644 index 00000000..dbe8d6b1 --- /dev/null +++ b/devplacepy/services/bot/posting.py @@ -0,0 +1,345 @@ +# retoor + +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 diff --git a/devplacepy/services/bot/social.py b/devplacepy/services/bot/social.py new file mode 100644 index 00000000..fd7c5140 --- /dev/null +++ b/devplacepy/services/bot/social.py @@ -0,0 +1,624 @@ +# retoor + +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)