# retoor <retoor@molodetz.nl>
import asyncio
import hashlib
import logging
import random
from devplacepy.services.bot.config import (
HOME_URL,
MAX_THREAD_REPLIES,
MENTION_REPLIES_PER_SESSION,
PROJECT_STATUSES,
bio_has_signal,
ensure_bio_signal,
)
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].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 _profile_has_signal(self) -> bool:
profile = await self._fetch_own_profile()
user = profile.get("profile_user") or {}
website = (user.get("website") or "").strip().rstrip("/")
return bio_has_signal(user.get("bio") or "") and website == HOME_URL
async def _ensure_profile_signal(self) -> bool:
if self.state.profile_filled and await self._profile_has_signal():
return True
self._log("Profile signal missing, refreshing profile")
ok = await self._update_profile()
await self.b.goto(f"{self.base_url}/feed")
await self.b._idle(0.8, 2.0)
return ok
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 = ensure_bio_signal(bio[:400])[: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 "")[:125000]
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 = ""
if mentioner and mentioner.lower() == self.state.username.lower():
self._log("Mention target resolves to self, skipping")
return False
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 = self._sanitize_mentions(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)