# 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._profile_verified:
self._profile_verified = await self._ensure_profile_signal()
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")