# retoor <retoor@molodetz.nl>
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