forked from retoor/devplacepy
fix: correct spelling of "Update" in commit message to "Update"
This commit is contained in:
+169
-58
@@ -19,6 +19,9 @@ from devplacepy.services.bot.config import (
|
||||
GIST_LANGUAGES,
|
||||
HANDLE_SUFFIX_WORDS,
|
||||
MAX_SIBLING_COMMENTS,
|
||||
MAX_THREAD_REPLIES,
|
||||
MENTION_REPLIES_PER_SESSION,
|
||||
NOTIFICATION_CHECK_PROB,
|
||||
PERSONAS,
|
||||
PROJECT_STATUSES,
|
||||
PROJECT_TYPES,
|
||||
@@ -85,6 +88,7 @@ class DevPlaceBot:
|
||||
)
|
||||
self._session_comments = 0
|
||||
self._session_comment_cap = 0
|
||||
self._session_mention_replies = 0
|
||||
self._post_cache: list[tuple[str, str, str, str, str]] = []
|
||||
self._project_cache: list[tuple[str, str]] = []
|
||||
self._bug_cache: list[tuple[str, str]] = []
|
||||
@@ -412,6 +416,22 @@ class DevPlaceBot:
|
||||
logger.debug("page summary count failed: %s", e)
|
||||
return summary
|
||||
|
||||
async def _unread_notification_count(self) -> int:
|
||||
try:
|
||||
badge = self.b.page.locator(
|
||||
"a[data-counter='notifications'] .nav-badge[data-counter-badge]"
|
||||
).first
|
||||
if await badge.count() == 0:
|
||||
return 0
|
||||
if await badge.get_attribute("hidden") is not None:
|
||||
return 0
|
||||
text = (await badge.inner_text() or "").strip()
|
||||
digits = re.sub(r"[^0-9]", "", text)
|
||||
return int(digits) if digits else 0
|
||||
except Exception as e:
|
||||
logger.debug("_unread_notification_count failed: %s", e)
|
||||
return 0
|
||||
|
||||
async def _page_state(
|
||||
self,
|
||||
curl: str,
|
||||
@@ -455,6 +475,7 @@ class DevPlaceBot:
|
||||
"can_project": can_project,
|
||||
"can_post": can_post,
|
||||
"can_message": can_message,
|
||||
"unread_notifications": await self._unread_notification_count(),
|
||||
}
|
||||
|
||||
def _build_menu(self, page_state: dict) -> list[dict]:
|
||||
@@ -519,6 +540,11 @@ class DevPlaceBot:
|
||||
add("open_profile", "open another developer's profile")
|
||||
add("search", "search the platform for a topic you care about")
|
||||
add("browse", "browse a category or section")
|
||||
if page != "notifications" and page_state.get("unread_notifications"):
|
||||
add(
|
||||
"check_notifications",
|
||||
"open your notifications and reply to anyone who mentioned or replied to you",
|
||||
)
|
||||
add("navigate", "move to another section: feed, projects, gists, or news")
|
||||
return menu
|
||||
|
||||
@@ -1888,69 +1914,108 @@ class DevPlaceBot:
|
||||
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
|
||||
item_selectors = [
|
||||
".notification-item",
|
||||
".notif-item",
|
||||
"li.notification",
|
||||
".notifications-list > li",
|
||||
".notifications-list > a",
|
||||
".notification",
|
||||
"[data-notification-id]",
|
||||
]
|
||||
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 sel in item_selectors:
|
||||
loc = b.page.locator(sel)
|
||||
for i in range(min(count, 30)):
|
||||
card = cards.nth(i)
|
||||
text = ""
|
||||
try:
|
||||
count = await loc.count()
|
||||
body = card.locator(".notification-text").first
|
||||
if await body.count() > 0:
|
||||
text = (await body.inner_text() or "")[:600]
|
||||
except Exception:
|
||||
count = 0
|
||||
if count == 0:
|
||||
continue
|
||||
for i in range(min(count, 30)):
|
||||
el = loc.nth(i)
|
||||
text = ""
|
||||
if not text:
|
||||
try:
|
||||
text = (await el.inner_text() or "")[:600]
|
||||
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 = ""
|
||||
try:
|
||||
href_attr = await el.get_attribute("href")
|
||||
except Exception:
|
||||
href_attr = None
|
||||
if href_attr:
|
||||
href = href_attr
|
||||
else:
|
||||
try:
|
||||
inner = el.locator(
|
||||
"a[href*='/posts/'], a[href*='/profile/']"
|
||||
).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)
|
||||
tl = text.lower()
|
||||
uname = self.state.username.lower()
|
||||
is_mention = (
|
||||
f"@{uname}" in tl
|
||||
or "mentioned you" in tl
|
||||
or "replied to your" in tl
|
||||
or "replied to you" in tl
|
||||
or "tagged you" in tl
|
||||
)
|
||||
items.append(
|
||||
{"key": key, "href": href, "text": text, "is_mention": is_mention}
|
||||
)
|
||||
if items:
|
||||
break
|
||||
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:
|
||||
@@ -1964,17 +2029,25 @@ class DevPlaceBot:
|
||||
unseen_mentions = [
|
||||
n
|
||||
for n in notifications
|
||||
if n["is_mention"] and n["key"] not in self.state.notifications_seen
|
||||
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[:3]:
|
||||
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:
|
||||
@@ -2016,7 +2089,7 @@ class DevPlaceBot:
|
||||
target = href
|
||||
if target and not target.startswith("http"):
|
||||
target = self.base_url + (href if href.startswith("/") else "/" + href)
|
||||
if "/posts/" not in (target or ""):
|
||||
if not target:
|
||||
return False
|
||||
try:
|
||||
await b.goto(target)
|
||||
@@ -2024,6 +2097,19 @@ class DevPlaceBot:
|
||||
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 = [
|
||||
@@ -2073,7 +2159,14 @@ class DevPlaceBot:
|
||||
logger.debug("reply-btn click failed: %s", e)
|
||||
try:
|
||||
post_body = (
|
||||
await b.text(".post-content") or await b.text("article") or snippet
|
||||
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
|
||||
@@ -2119,6 +2212,13 @@ class DevPlaceBot:
|
||||
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
|
||||
@@ -2660,6 +2760,7 @@ class DevPlaceBot:
|
||||
self._session_comment_cap = random.randint(
|
||||
SESSION_COMMENT_CAP_MIN, SESSION_COMMENT_CAP_MAX
|
||||
)
|
||||
self._session_mention_replies = 0
|
||||
use_ai = False
|
||||
|
||||
try:
|
||||
@@ -2691,14 +2792,24 @@ class DevPlaceBot:
|
||||
await b.goto(f"{self.base_url}/feed")
|
||||
await b._idle(0.8, 2.0)
|
||||
|
||||
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")
|
||||
await b.goto(f"{self.base_url}/feed")
|
||||
|
||||
session_actions = 0
|
||||
max_actions = session_len + random.randint(0, 5)
|
||||
|
||||
@@ -32,6 +32,15 @@ SESSION_COMMENT_CAP_MAX = 3
|
||||
COMMENT_COOLDOWN_MIN_SECONDS = 60
|
||||
COMMENT_COOLDOWN_MAX_SECONDS = 240
|
||||
|
||||
MENTION_REPLIES_PER_SESSION = 3
|
||||
MAX_THREAD_REPLIES = 3
|
||||
NOTIFICATION_CHECK_PROB = {
|
||||
"lurking": 0.6,
|
||||
"casual": 0.85,
|
||||
"normal": 1.0,
|
||||
"deep": 1.0,
|
||||
}
|
||||
|
||||
HANDLE_SUFFIX_WORDS = [
|
||||
"dev",
|
||||
"codes",
|
||||
|
||||
@@ -524,9 +524,15 @@ class LLMClient:
|
||||
def _decision_prompt(page_state: dict, menu: list[dict], history: list[str]) -> str:
|
||||
menu_lines = "\n".join(f"- {item['action']}: {item['desc']}" for item in menu)
|
||||
recent = "; ".join(history[-8:]) if history else "nothing yet this visit"
|
||||
unread = page_state.get("unread_notifications", 0)
|
||||
unread_line = (
|
||||
f"\nUNREAD NOTIFICATIONS: {unread} (someone may have mentioned or replied to you)"
|
||||
if unread
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
f"PAGE: {page_state.get('page', 'unknown')}\n"
|
||||
f"VISIBLE: {page_state.get('visible', '')}\n"
|
||||
f"VISIBLE: {page_state.get('visible', '')}{unread_line}\n"
|
||||
f"MENU:\n{menu_lines}\n"
|
||||
f"RECENT (this visit): {recent}"
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ class BotState:
|
||||
voted_post_ids: list[str] = field(default_factory=list)
|
||||
commented_post_ids: list[str] = field(default_factory=list)
|
||||
notifications_seen: list[str] = field(default_factory=list)
|
||||
thread_reply_counts: dict = field(default_factory=dict)
|
||||
mentions_received: int = 0
|
||||
mentions_replied: int = 0
|
||||
replies_to_own_posts: int = 0
|
||||
|
||||
Reference in New Issue
Block a user