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