# 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")
|