montana/Русский/Логистика/moltbook_bot.py

514 lines
19 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
Moltbook Bot SeaFare Montana promoter on Moltbook (Reddit for AI agents).
Searches for maritime-related posts and comments with expert insights + promotion.
Run standalone: python moltbook_bot.py
Or auto-started as background thread from seafare_api.py
"""
import json
import os
import random
import re
import threading
import time
from datetime import datetime
import requests
# ---------------------------------------------------------------------------
# CONFIG
# ---------------------------------------------------------------------------
MOLTBOOK_BASE = "https://www.moltbook.com/api/v1"
AGENT_NAME = "SeaFare_Montana"
AGENT_DESC = (
"Maritime freight AI agent. 24 tools, 16,553 ports worldwide. "
"Vessel tracking, route calculation, freight rates, sanctions screening, "
"charter party generation, dark fleet detection and more. "
"Built by maritime professionals with 25 years of industry experience. "
"Try: https://seafare-montana.onrender.com"
)
SEARCH_KEYWORDS = [
"sea shipping", "freight rate", "vessel tracking", "maritime",
"supply chain", "sanctions", "oil tanker", "bunker fuel",
"port congestion", "cargo ship", "tanker vessel", "bulk carrier",
"chartering", "bill of lading", "demurrage", "dark fleet",
"container ship", "dry bulk", "port call",
]
LOOP_INTERVAL_SEC = 30 * 60 # 30 minutes
MAX_COMMENTS_PER_CYCLE = 3 # Don't spam — max 3 comments per 30 min
POST_MAX_AGE_HOURS = 24 # Don't comment on posts older than 24h
MIN_KEYWORD_MATCHES = 2 # At least 2 keyword matches for relevance
# Claude model for comment generation (haiku = cheap + fast)
COMMENT_MODEL = "claude-haiku-4-5-20251001"
COMMENT_SYSTEM_PROMPT = """You are SeaFare_Montana — a maritime freight operations specialist with 25 years of industry experience, posting on Moltbook (a social platform for AI agents).
RULES:
- Read the post carefully and respond with genuine maritime expertise
- Keep comments 2-4 sentences. Be concise, insightful, and professional.
- Add real value share industry knowledge, data points, or practical advice
- Naturally mention SeaFare Montana ONLY when truly relevant (don't force it)
- When relevant, mention: "We handle this at SeaFare Montana — 24 maritime tools, 16K+ ports, vessel tracking, route calculation, sanctions screening. Try seafare-montana.onrender.com"
- Don't be salesy. Be the expert who happens to have a great platform.
- If the post is tangentially related to maritime but not directly just give a helpful comment without promotion
- Write in English
- Never use phrases like "Great question!" or "I'd love to help" just deliver expertise
- Use specific numbers and facts when possible (port counts, distance examples, rate benchmarks)
- If the post is NOT about maritime/shipping/vessels/logistics respond with EXACTLY the word "SKIP" and nothing else"""
# ---------------------------------------------------------------------------
# MOLTBOOK API CLIENT
# ---------------------------------------------------------------------------
class MoltbookClient:
"""HTTP client for Moltbook API with rate limiting."""
def __init__(self, api_key: str):
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
})
self._request_count = 0
self._stopped = False
def _throttle(self):
"""Rate limit: 1 request per second, max 50 per cycle."""
self._request_count += 1
if self._request_count > 50:
print("[Moltbook] Rate limit: 50 requests per cycle reached, pausing")
return False
time.sleep(1)
return True
def _get(self, path: str, params: dict = None) -> dict:
if not self._throttle():
return {}
url = f"{MOLTBOOK_BASE}{path}"
try:
r = self.session.get(url, params=params, timeout=15)
if r.status_code == 429:
print(f"[Moltbook] Rate limited (429) on GET {path}, waiting 60s")
time.sleep(60)
return {}
if r.status_code in (401, 403):
print(f"[Moltbook] Auth error ({r.status_code}) on GET {path} — stopping bot")
self._stopped = True
return {}
r.raise_for_status()
return r.json()
except requests.exceptions.RequestException as e:
print(f"[Moltbook] GET {path} error: {e}")
return {}
def _post(self, path: str, data: dict = None) -> dict:
if not self._throttle():
return {}
url = f"{MOLTBOOK_BASE}{path}"
try:
r = self.session.post(url, json=data or {}, timeout=15)
if r.status_code == 429:
print(f"[Moltbook] Rate limited (429) on POST {path}, waiting 60s")
time.sleep(60)
return {}
if r.status_code in (401, 403):
print(f"[Moltbook] Auth error ({r.status_code}) on POST {path} — stopping bot")
self._stopped = True
return {}
r.raise_for_status()
return r.json()
except requests.exceptions.RequestException as e:
print(f"[Moltbook] POST {path} error: {e}")
return {}
def _delete(self, path: str) -> dict:
if not self._throttle():
return {}
url = f"{MOLTBOOK_BASE}{path}"
try:
r = self.session.delete(url, timeout=15)
if r.status_code == 429:
print(f"[Moltbook] Rate limited (429) on DELETE {path}, waiting 60s")
time.sleep(60)
return {}
if r.status_code in (401, 403):
print(f"[Moltbook] Auth error ({r.status_code}) on DELETE {path} — stopping bot")
self._stopped = True
return {}
r.raise_for_status()
return r.json()
except requests.exceptions.RequestException as e:
print(f"[Moltbook] DELETE {path} error: {e}")
return {}
# --- Agent ---
def register_agent(self, name: str, description: str) -> dict:
return self._post("/agents/register", {"name": name, "description": description})
def get_profile(self) -> dict:
return self._get("/agents/me")
def update_profile(self, description: str) -> dict:
if not self._throttle():
return {}
url = f"{MOLTBOOK_BASE}/agents/me"
try:
r = self.session.patch(url, json={"description": description}, timeout=15)
if r.status_code == 429:
print(f"[Moltbook] Rate limited (429) on PATCH /agents/me, waiting 60s")
time.sleep(60)
return {}
if r.status_code in (401, 403):
print(f"[Moltbook] Auth error ({r.status_code}) on PATCH /agents/me — stopping bot")
self._stopped = True
return {}
r.raise_for_status()
return r.json()
except requests.exceptions.RequestException as e:
print(f"[Moltbook] PATCH /agents/me error: {e}")
return {}
# --- Posts ---
def search_posts(self, query: str, limit: int = 25) -> list:
result = self._get("/search", {"q": query, "limit": limit})
return result.get("posts", result.get("results", []))
def get_post(self, post_id: str) -> dict:
return self._get(f"/posts/{post_id}")
def get_feed(self, sort: str = "new", limit: int = 25) -> list:
result = self._get("/posts", {"sort": sort, "limit": limit})
if isinstance(result, list):
return result
return result.get("posts", result.get("data", []))
# --- Comments ---
def get_comments(self, post_id: str) -> list:
result = self._get(f"/posts/{post_id}/comments", {"sort": "top"})
if isinstance(result, list):
return result
return result.get("comments", result.get("data", []))
def create_comment(self, post_id: str, body: str) -> dict:
return self._post(f"/posts/{post_id}/comments", {"body": body})
# --- Voting ---
def upvote_post(self, post_id: str) -> dict:
return self._post(f"/posts/{post_id}/upvote")
# --- Submolts ---
def list_submolts(self) -> list:
result = self._get("/submolts")
if isinstance(result, list):
return result
return result.get("submolts", result.get("data", []))
def subscribe(self, submolt_name: str) -> dict:
return self._post(f"/submolts/{submolt_name}/subscribe")
# --- Create post ---
def create_post(self, submolt: str, title: str, body: str) -> dict:
return self._post("/posts", {
"submolt": submolt,
"title": title,
"body": body,
})
# ---------------------------------------------------------------------------
# COMMENT GENERATION (Claude API)
# ---------------------------------------------------------------------------
def generate_comment(post_title: str, post_body: str, existing_comments: list = None) -> str:
"""Generate an expert comment using Claude API."""
try:
import anthropic
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
except Exception as e:
print(f"[Moltbook] Claude API init error: {e}")
return ""
# Build context
context = f"POST TITLE: {post_title}\n\nPOST BODY: {post_body or '(no body)'}"
if existing_comments:
top_comments = existing_comments[:3]
comments_text = "\n".join(
f"- {c.get('agent_name', 'anon')}: {c.get('body', '')[:200]}"
for c in top_comments
)
context += f"\n\nEXISTING TOP COMMENTS:\n{comments_text}"
context += "\n\nWrite your expert comment (2-4 sentences):"
try:
response = client.messages.create(
model=COMMENT_MODEL,
max_tokens=300,
system=COMMENT_SYSTEM_PROMPT,
messages=[{"role": "user", "content": context}],
)
return response.content[0].text.strip()
except Exception as e:
print(f"[Moltbook] Claude API error: {e}")
return ""
# ---------------------------------------------------------------------------
# RELEVANCE FILTER
# ---------------------------------------------------------------------------
def is_relevant(post: dict) -> bool:
"""Check if a post is relevant to maritime/shipping topics (word boundary matching)."""
title = (post.get("title") or "").lower()
body = (post.get("body") or post.get("content") or "").lower()
text = title + " " + body
matches = sum(1 for kw in SEARCH_KEYWORDS if re.search(r'\b' + re.escape(kw) + r'\b', text))
return matches >= MIN_KEYWORD_MATCHES
def post_age_hours(post: dict) -> float:
"""Estimate post age in hours."""
created = post.get("created_at") or post.get("created")
if not created:
return 999 # Unknown age = skip
try:
if isinstance(created, str):
# Try ISO format
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d %H:%M:%S"):
try:
dt = datetime.strptime(created, fmt)
return (datetime.utcnow() - dt).total_seconds() / 3600
except ValueError:
continue
return 999
except Exception:
return 999
# ---------------------------------------------------------------------------
# DATABASE HELPERS (import from maritime_db)
# ---------------------------------------------------------------------------
def _get_db():
"""Lazy import to avoid circular imports."""
import maritime_db as db
return db
def has_commented(post_id: str) -> bool:
db = _get_db()
return db.has_commented_post(str(post_id))
def save_comment(post_id: str, title: str, body: str):
db = _get_db()
db.save_moltbook_comment(str(post_id), title, body)
# ---------------------------------------------------------------------------
# BOT LOOP
# ---------------------------------------------------------------------------
def bot_cycle(client: MoltbookClient):
"""Single bot cycle: search → filter → comment."""
print(f"[Moltbook] Starting cycle at {datetime.now().strftime('%H:%M:%S')}")
client._request_count = 0 # Reset per-cycle counter
# Rotate through keywords to get diverse results
keyword = random.choice(SEARCH_KEYWORDS)
print(f"[Moltbook] Searching for: {keyword}")
posts = client.search_posts(keyword, limit=25)
if not posts:
# Fallback to feed
print("[Moltbook] No search results, checking feed...")
posts = client.get_feed(sort="new", limit=25)
if not posts:
print("[Moltbook] No posts found")
return
commented = 0
for post in posts:
if commented >= MAX_COMMENTS_PER_CYCLE:
break
post_id = str(post.get("id", post.get("_id", "")))
title = post.get("title", "")
if not post_id or not title:
continue
# Skip if already commented
if has_commented(post_id):
continue
# Skip old posts
age = post_age_hours(post)
if age > POST_MAX_AGE_HOURS:
continue
# Check relevance
if not is_relevant(post):
continue
print(f"[Moltbook] Found relevant post: {title[:60]}... (age: {age:.1f}h)")
# Get existing comments for context
existing = client.get_comments(post_id)
# Check if we already have a comment there (belt + suspenders)
our_comments = [c for c in existing if (c.get("agent_name") or "").lower() == AGENT_NAME.lower()]
if our_comments:
save_comment(post_id, title, "(already commented)")
continue
# Generate comment
body = post.get("body") or post.get("content") or ""
comment_text = generate_comment(title, body, existing)
# Claude said SKIP — not relevant enough (check BEFORE length check!)
if comment_text and comment_text.strip().upper() == "SKIP":
print(f"[Moltbook] Claude says SKIP — not maritime enough")
save_comment(post_id, title, "(skipped)") # Mark as processed so we don't retry
continue
if not comment_text or len(comment_text) < 20:
print(f"[Moltbook] Skipping — generated comment too short")
continue
# Post comment
result = client.create_comment(post_id, comment_text)
if result and not result.get("error"):
print(f"[Moltbook] Commented on: {title[:60]}")
print(f"[Moltbook] Comment: {comment_text[:100]}...")
save_comment(post_id, title, comment_text)
# Also upvote the post (be nice)
client.upvote_post(post_id)
commented += 1
# Small delay between comments
time.sleep(5)
else:
print(f"[Moltbook] Failed to comment on post {post_id}")
print(f"[Moltbook] Cycle done. Commented on {commented} posts.")
def bot_loop(client: MoltbookClient):
"""Main bot loop — runs forever (stops on auth errors)."""
print("[Moltbook] Bot loop started")
while not client._stopped:
try:
bot_cycle(client)
except Exception as e:
print(f"[Moltbook] Cycle error: {e}")
if client._stopped:
print("[Moltbook] Bot stopped due to auth error")
break
# Wait for next cycle (with jitter to avoid predictable patterns)
jitter = random.randint(-120, 120) # ±2 min jitter
sleep_sec = LOOP_INTERVAL_SEC + jitter
print(f"[Moltbook] Sleeping {sleep_sec // 60} min until next cycle...")
time.sleep(sleep_sec)
# ---------------------------------------------------------------------------
# STARTUP
# ---------------------------------------------------------------------------
def ensure_agent_registered(client: MoltbookClient):
"""Register or verify agent profile on Moltbook."""
resp = client.get_profile()
# API returns {"success": true, "agent": {...}} or flat dict
profile = resp.get("agent", resp) if isinstance(resp, dict) else {}
if profile and profile.get("name"):
print(f"[Moltbook] Agent active: {profile['name']} (karma: {profile.get('karma', 0)})")
# Update description if needed
if profile.get("description", "") != AGENT_DESC:
client.update_profile(AGENT_DESC)
print("[Moltbook] Profile description updated")
return True
# Try to register
print("[Moltbook] Registering agent...")
result = client.register_agent(AGENT_NAME, AGENT_DESC)
agent = result.get("agent", result) if isinstance(result, dict) else {}
if agent and agent.get("name"):
print(f"[Moltbook] Agent registered: {agent['name']}")
return True
print(f"[Moltbook] Registration result: {result}")
return True # Continue anyway — might already be registered
def start_moltbook_bot():
"""Start Moltbook bot as a background daemon thread.
Called from seafare_api.py on startup. Only one instance per process."""
if os.environ.get("_MOLTBOOK_STARTED"):
print("[Moltbook] Bot already started in this process — skipping")
return
os.environ["_MOLTBOOK_STARTED"] = "1"
api_key = os.environ.get("MOLTBOOK_API_KEY")
anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
print("[Moltbook] MOLTBOOK_API_KEY not set — bot disabled")
return
if not anthropic_key:
print("[Moltbook] ANTHROPIC_API_KEY not set — bot disabled")
return
client = MoltbookClient(api_key)
# Verify/register agent
ensure_agent_registered(client)
# Start background loop
t = threading.Thread(target=bot_loop, args=(client,), daemon=True)
t.start()
print("[Moltbook] Bot thread started (30 min cycle)")
# ---------------------------------------------------------------------------
# STANDALONE RUN
# ---------------------------------------------------------------------------
if __name__ == "__main__":
from dotenv import load_dotenv
load_dotenv()
api_key = os.environ.get("MOLTBOOK_API_KEY")
if not api_key:
print("Set MOLTBOOK_API_KEY in .env")
exit(1)
client = MoltbookClient(api_key)
# Test: profile
print("\n--- Agent Profile ---")
ensure_agent_registered(client)
profile = client.get_profile()
print(json.dumps(profile, indent=2, default=str))
# Test: search
print("\n--- Search 'shipping' ---")
posts = client.search_posts("shipping", limit=5)
for p in posts[:5]:
pid = p.get("id", p.get("_id", "?"))
title = p.get("title", "?")
print(f" [{pid}] {title[:70]}")
# Test: single cycle
print("\n--- Running single bot cycle ---")
bot_cycle(client)
print("\nDone!")