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