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:
2026-06-15 22:44:12 +00:00
parent 5dd2126511
commit e59bc2d34e
27 changed files with 375 additions and 100 deletions
+58 -9
View File
@@ -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:
+18 -3
View File
@@ -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)
-4
View File
@@ -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)
+11 -1
View File
@@ -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,
+2
View File
@@ -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: