forked from retoor/devplacepy
feat: adopt per-bot account api key for gateway attribution and improve post distinctness
- Add `account_api_key` field to `BotState` and `_adopt_account_api_key()` method that fetches the bot's own `users.api_key` from its profile JSON after auth, switching `LLMClient` to use it instead of the shared bootstrap key, routing gateway spend through the bot's user identity - Replace `interleave_by_author` with a simpler greedy algorithm that picks the first row whose owner differs from the last picked, removing the multi-queue priority logic - Extend `_ai_quota` response with `unlimited`, `requests`, and `last_used` fields sourced from new `user_spend_24h()` analytics helper - Pass `recent_post_titles` from `BotState` into `LLMClient.generate_post()` and add a `distinct_rule` prompt instructing the model to avoid repeating framing, examples, or opinions from the bot's last six posts - Remove early-return dedup check in `ArticleRegistry.admit` that skipped articles already held by the same bot owner, allowing re-admission under a different category - Render bot username as a clickable link with avatar in the admin bots table
This commit is contained in:
@@ -55,8 +55,9 @@ class DevPlaceBot:
|
||||
self.state = BotState.load(cfg.state_path)
|
||||
if not self.state.persona:
|
||||
self.state.persona = random.choice(PERSONAS)
|
||||
self._gateway_fallback_key = cfg.api_key
|
||||
self.llm = LLMClient(
|
||||
cfg.api_key,
|
||||
self.state.account_api_key or cfg.api_key,
|
||||
cfg.api_url,
|
||||
cfg.model,
|
||||
cfg.input_cost_per_1m,
|
||||
@@ -212,6 +213,7 @@ class DevPlaceBot:
|
||||
desc,
|
||||
self.state.persona,
|
||||
cat,
|
||||
self.state.recent_post_titles,
|
||||
)
|
||||
self._post_cache.append(
|
||||
(a["title"], post_title or "", desc, content, cat)
|
||||
@@ -314,6 +316,37 @@ class DevPlaceBot:
|
||||
self._log(f"Signup failed, retrying as {self.state.username}")
|
||||
return False
|
||||
|
||||
async def _fetch_account_api_key(self) -> str:
|
||||
if not self.state.username:
|
||||
return ""
|
||||
try:
|
||||
key = await self.b.page.evaluate(
|
||||
"""async (username) => {
|
||||
const resp = await fetch(`/profile/${username}`, {
|
||||
headers: {Accept: 'application/json'},
|
||||
});
|
||||
if (!resp.ok) return '';
|
||||
const data = await resp.json();
|
||||
return data.api_key || '';
|
||||
}""",
|
||||
self.state.username,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("fetch account api key failed: %s", e)
|
||||
return ""
|
||||
return (key or "").strip()
|
||||
|
||||
async def _adopt_account_api_key(self) -> None:
|
||||
if self.state.account_api_key:
|
||||
return
|
||||
key = await self._fetch_account_api_key()
|
||||
if not key:
|
||||
return
|
||||
self.state.account_api_key = key
|
||||
self.llm.api_key = key
|
||||
self._save()
|
||||
self._log("Adopted account API key for gateway calls")
|
||||
|
||||
async def _read_like_human(self, min_s: float = 3.0, max_s: float = 90.0) -> None:
|
||||
if not self.state.reading_speed_wpm:
|
||||
self.state.reading_speed_wpm = random.randint(180, 320)
|
||||
@@ -1540,6 +1573,7 @@ class DevPlaceBot:
|
||||
desc,
|
||||
self.state.persona,
|
||||
topic,
|
||||
self.state.recent_post_titles,
|
||||
)
|
||||
if not content:
|
||||
self._log("Create post: generation failed/empty")
|
||||
@@ -1559,6 +1593,7 @@ class DevPlaceBot:
|
||||
article_desc,
|
||||
self.state.persona,
|
||||
topic,
|
||||
self.state.recent_post_titles,
|
||||
)
|
||||
if not content:
|
||||
self._log("Create post: regeneration failed/empty")
|
||||
@@ -1588,7 +1623,7 @@ class DevPlaceBot:
|
||||
if not type_title:
|
||||
type_title = clean_title
|
||||
|
||||
ts = f"input[name='topic'][value='{topic}']"
|
||||
ts = f"#create-post-modal input[name='topic'][value='{topic}']"
|
||||
if await b.page.locator(ts).count() == 0:
|
||||
self._log(f"Create post: topic radio '{topic}' not found")
|
||||
else:
|
||||
@@ -1596,16 +1631,18 @@ class DevPlaceBot:
|
||||
await b._idle(0.2, 0.5)
|
||||
|
||||
if type_title:
|
||||
ok = await b.fill("input[name='title']", type_title)
|
||||
ok = await b.fill("#create-post-modal input[name='title']", type_title)
|
||||
if not ok:
|
||||
self._log("Create post: title input not found")
|
||||
await b._idle(0.2, 0.4)
|
||||
|
||||
content_sel = "textarea[name='content']"
|
||||
content_sel = "#create-post-modal textarea[name='content']"
|
||||
if await b.page.locator(content_sel).count() == 0:
|
||||
self._log("Create post: content textarea not found")
|
||||
return False
|
||||
await self._type_like_human(content_sel, content[:5000])
|
||||
if not await self._type_like_human(content_sel, content[:5000]):
|
||||
self._log("Create post: typing content into modal failed")
|
||||
return False
|
||||
await b._idle(0.5, 1.5)
|
||||
|
||||
submit_sels = [
|
||||
@@ -1623,13 +1660,23 @@ class DevPlaceBot:
|
||||
self._log("Create post: submit button not found")
|
||||
return False
|
||||
|
||||
post_url = await b.url()
|
||||
for _ in range(12):
|
||||
if "/posts/" in post_url:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
post_url = await b.url()
|
||||
if "/posts/" not in post_url:
|
||||
self._log(f"Create post: submit did not create a post (url={post_url[-45:]})")
|
||||
return False
|
||||
self.state.created_posts += 1
|
||||
self.state.posted_content_hashes.append(content_hash)
|
||||
await asyncio.sleep(2)
|
||||
post_url = await b.url()
|
||||
if "/posts/" in post_url and post_url not in self.state.own_post_urls:
|
||||
if type_title:
|
||||
self.state.recent_post_titles.append(type_title)
|
||||
self.state.recent_post_titles = self.state.recent_post_titles[-8:]
|
||||
if post_url not in self.state.own_post_urls:
|
||||
self.state.own_post_urls.append(post_url)
|
||||
if "/posts/" in post_url and post_url not in self.state.known_posts:
|
||||
if post_url not in self.state.known_posts:
|
||||
self.state.known_posts.append(post_url)
|
||||
self._action(
|
||||
"POST",
|
||||
@@ -2786,6 +2833,8 @@ class DevPlaceBot:
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
await self._adopt_account_api_key()
|
||||
|
||||
self._bind_monitor()
|
||||
|
||||
if self._ai_decisions:
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -156,7 +156,12 @@ class LLMClient:
|
||||
return text.strip()
|
||||
|
||||
def generate_post(
|
||||
self, title: str, desc: str, persona: str = "", category: str = ""
|
||||
self,
|
||||
title: str,
|
||||
desc: str,
|
||||
persona: str = "",
|
||||
category: str = "",
|
||||
recent_titles: Optional[list[str]] = None,
|
||||
) -> str:
|
||||
persona_extras = {
|
||||
"enthusiastic_junior": "Be excited. Use **bold** for emphasis. Short excited sentences. End with a question sometimes.",
|
||||
@@ -182,6 +187,15 @@ class LLMClient:
|
||||
else " Be casual. No markdown."
|
||||
)
|
||||
category_extra = f" {category_extras.get(category, '')}" if category else ""
|
||||
distinct_rule = ""
|
||||
if recent_titles:
|
||||
recent = "; ".join(t for t in recent_titles[-6:] if t)
|
||||
if recent:
|
||||
distinct_rule = (
|
||||
f" You already posted about: {recent[:600]}. Take a completely "
|
||||
"different angle from those and do not repeat their framing, "
|
||||
"examples, or opinions."
|
||||
)
|
||||
preserve = persona in (
|
||||
"enthusiastic_junior",
|
||||
"hobbyist_maker",
|
||||
@@ -193,7 +207,8 @@ class LLMClient:
|
||||
f"You are a dev writing a social media post reacting to tech news. Write 2-4 short paragraphs."
|
||||
f"{persona_extra}{category_extra} Do not summarize the article; assume the reader already saw it. "
|
||||
f"Add your own value: a concrete opinion, an implication, a personal experience, or a pointed question. "
|
||||
f"Reference a specific detail rather than restating the headline. No em dashes. Under 300 words.",
|
||||
f"Reference a specific detail rather than restating the headline. No em dashes. Under 300 words."
|
||||
f"{distinct_rule}",
|
||||
f"News: {title}\n\n{desc[:800]}",
|
||||
)
|
||||
return self.clean(text, preserve_md=preserve)
|
||||
|
||||
@@ -121,10 +121,6 @@ class ArticleRegistry:
|
||||
return True
|
||||
try:
|
||||
entry = registry.get(title)
|
||||
if entry is not None and any(
|
||||
h.get("bot") == owner for h in entry.get("holders", [])
|
||||
):
|
||||
return True
|
||||
if not self._admit(entry, owner, category):
|
||||
return False
|
||||
self._hold(registry, title, owner, category)
|
||||
|
||||
@@ -356,10 +356,20 @@ class BotsService(BaseService):
|
||||
posts += st.created_posts
|
||||
comments += st.comments_posted
|
||||
votes += st.votes_cast
|
||||
username = (st.username if st else "") or ""
|
||||
user_cell = (
|
||||
{
|
||||
"text": username,
|
||||
"href": f"/profile/{username}",
|
||||
"avatar": username,
|
||||
}
|
||||
if username
|
||||
else "-"
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
f"bot{slot}",
|
||||
(st.username if st else "") or "-",
|
||||
user_cell,
|
||||
(st.persona if st else "") or "-",
|
||||
"running" if alive else "stopped",
|
||||
st.created_posts if st else 0,
|
||||
|
||||
@@ -14,6 +14,7 @@ class BotState:
|
||||
email: str = ""
|
||||
password: str = ""
|
||||
uid: str = ""
|
||||
account_api_key: str = ""
|
||||
persona: str = ""
|
||||
handle_candidates: list[str] = field(default_factory=list)
|
||||
profile_filled: bool = False
|
||||
@@ -25,6 +26,7 @@ class BotState:
|
||||
known_posts: list[str] = field(default_factory=list)
|
||||
created_posts: int = 0
|
||||
posted_content_hashes: list[str] = field(default_factory=list)
|
||||
recent_post_titles: list[str] = field(default_factory=list)
|
||||
comments_posted: int = 0
|
||||
quality_rejections: int = 0
|
||||
votes_cast: int = 0
|
||||
|
||||
@@ -453,6 +453,29 @@ def _user_hourly(rows: list[dict]) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def user_spend_24h(owner_id: str, hours: int = 24) -> dict:
|
||||
hours = max(1, min(hours, MAX_WINDOW_HOURS))
|
||||
zero = {"requests": 0, "spent_usd": 0.0, "last_used": None}
|
||||
if not owner_id or GATEWAY_LEDGER not in db.tables:
|
||||
return zero
|
||||
cutoff = _iso(_now() - timedelta(hours=hours))
|
||||
rows = list(
|
||||
db.query(
|
||||
f"SELECT COUNT(*) AS requests, COALESCE(SUM(cost_usd), 0) AS spent, "
|
||||
f"MAX(created_at) AS last_used FROM {GATEWAY_LEDGER} "
|
||||
"WHERE owner_id = :oid AND created_at >= :cutoff",
|
||||
oid=owner_id,
|
||||
cutoff=cutoff,
|
||||
)
|
||||
)
|
||||
row = rows[0] if rows else {}
|
||||
return {
|
||||
"requests": int(row.get("requests") or 0),
|
||||
"spent_usd": float(row.get("spent") or 0.0),
|
||||
"last_used": row.get("last_used"),
|
||||
}
|
||||
|
||||
|
||||
def build_user_usage(
|
||||
owner_id: str, hours: int = 24, pricing: Optional[Pricing] = None
|
||||
) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user