#!/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!")