"""X (Twitter) publisher — posts approved tweets to X. Handles the full tweet lifecycle: 1. Agent submits draft → output gate blocks system content 2. Draft enters approval_queue (type='tweet') 3. Leo reviews substance → Cory approves via Telegram 4. On approval, this module posts to X via API 5. Records published URL and metrics Uses Twitter API v2 via OAuth 1.0a for posting. Read operations still use twitterapi.io (x_client.py). Epimetheus owns this module. """ import json import hashlib import hmac import logging import sqlite3 import time import urllib.parse from pathlib import Path from typing import Optional import aiohttp logger = logging.getLogger("x-publisher") # ─── Config ────────────────────────────────────────────────────────── # Twitter API v2 credentials for posting # OAuth 1.0a keys — stored in separate secret files _SECRETS_DIR = Path("/opt/teleo-eval/secrets") _CONSUMER_KEY_FILE = _SECRETS_DIR / "x-consumer-key" _CONSUMER_SECRET_FILE = _SECRETS_DIR / "x-consumer-secret" _ACCESS_TOKEN_FILE = _SECRETS_DIR / "x-access-token" _ACCESS_SECRET_FILE = _SECRETS_DIR / "x-access-secret" TWITTER_API_V2_URL = "https://api.twitter.com/2/tweets" REQUEST_TIMEOUT = 15 def _load_secret(path: Path) -> Optional[str]: """Load a secret from a file. Returns None if missing.""" try: return path.read_text().strip() except Exception: return None def _load_oauth_credentials() -> Optional[dict]: """Load all 4 OAuth 1.0a credentials. Returns None if any missing.""" creds = { "consumer_key": _load_secret(_CONSUMER_KEY_FILE), "consumer_secret": _load_secret(_CONSUMER_SECRET_FILE), "access_token": _load_secret(_ACCESS_TOKEN_FILE), "access_secret": _load_secret(_ACCESS_SECRET_FILE), } missing = [k for k, v in creds.items() if not v] if missing: logger.warning("Missing X API credentials: %s", ", ".join(missing)) return None return creds # ─── OAuth 1.0a Signature ──────────────────────────────────────────── def _percent_encode(s: str) -> str: return urllib.parse.quote(str(s), safe="") def _generate_oauth_signature( method: str, url: str, params: dict, consumer_secret: str, token_secret: str, ) -> str: """Generate OAuth 1.0a signature.""" sorted_params = "&".join( f"{_percent_encode(k)}={_percent_encode(v)}" for k, v in sorted(params.items()) ) base_string = f"{method.upper()}&{_percent_encode(url)}&{_percent_encode(sorted_params)}" signing_key = f"{_percent_encode(consumer_secret)}&{_percent_encode(token_secret)}" signature = hmac.new( signing_key.encode(), base_string.encode(), hashlib.sha1 ).digest() import base64 return base64.b64encode(signature).decode() def _build_oauth_header( method: str, url: str, creds: dict, extra_params: dict = None, ) -> str: """Build the OAuth 1.0a Authorization header.""" import uuid oauth_params = { "oauth_consumer_key": creds["consumer_key"], "oauth_nonce": uuid.uuid4().hex, "oauth_signature_method": "HMAC-SHA1", "oauth_timestamp": str(int(time.time())), "oauth_token": creds["access_token"], "oauth_version": "1.0", } # Combine oauth params with any extra params for signature all_params = {**oauth_params} if extra_params: all_params.update(extra_params) signature = _generate_oauth_signature( method, url, all_params, creds["consumer_secret"], creds["access_secret"], ) oauth_params["oauth_signature"] = signature header_parts = ", ".join( f'{_percent_encode(k)}="{_percent_encode(v)}"' for k, v in sorted(oauth_params.items()) ) return f"OAuth {header_parts}" # ─── Tweet Submission ──────────────────────────────────────────────── def submit_tweet_draft( conn: sqlite3.Connection, content: str, agent: str, context: dict = None, reply_to_url: str = None, post_type: str = "original", ) -> tuple[int, str]: """Submit a tweet draft to the approval queue. Returns (request_id, status_message). status_message is None on success, error string on failure. The output gate and OPSEC filter run before insertion. """ # Import here to avoid circular dependency from output_gate import gate_for_tweet_queue from approvals import check_opsec # Output gate — block system content gate = gate_for_tweet_queue(content, agent) if not gate: return -1, f"Output gate blocked: {', '.join(gate.blocked_reasons)}" # OPSEC filter opsec_violation = check_opsec(content) if opsec_violation: return -1, opsec_violation # Build context JSON ctx = { "post_type": post_type, "target_account": "TeleoHumanity", # default, can be overridden } if reply_to_url: ctx["reply_to_url"] = reply_to_url if context: ctx.update(context) # Insert into approval queue cursor = conn.execute( """INSERT INTO approval_queue (type, content, originating_agent, context, leo_review_status, expires_at) VALUES (?, ?, ?, ?, 'pending_leo', datetime('now', '+24 hours'))""", ("tweet", content, agent, json.dumps(ctx)), ) conn.commit() request_id = cursor.lastrowid logger.info("Tweet draft #%d submitted by %s (%d chars)", request_id, agent, len(content)) return request_id, None # ─── Tweet Posting ─────────────────────────────────────────────────── async def post_tweet(text: str, reply_to_id: str = None) -> dict: """Post a tweet to X via Twitter API v2. Returns dict with: - success: bool - tweet_id: str (if successful) - tweet_url: str (if successful) - error: str (if failed) """ creds = _load_oauth_credentials() if not creds: return {"success": False, "error": "X API credentials not configured"} # Build request body body = {"text": text} if reply_to_id: body["reply"] = {"in_reply_to_tweet_id": reply_to_id} # OAuth 1.0a header (for JSON body, don't include body params in signature) auth_header = _build_oauth_header("POST", TWITTER_API_V2_URL, creds) headers = { "Authorization": auth_header, "Content-Type": "application/json", } try: async with aiohttp.ClientSession() as session: async with session.post( TWITTER_API_V2_URL, headers=headers, json=body, timeout=aiohttp.ClientTimeout(total=REQUEST_TIMEOUT), ) as resp: result = await resp.json() if resp.status == 201: tweet_id = result.get("data", {}).get("id", "") return { "success": True, "tweet_id": tweet_id, "tweet_url": f"https://x.com/TeleoHumanity/status/{tweet_id}", } else: error = result.get("detail") or result.get("title") or str(result) logger.error("Tweet post failed (%d): %s", resp.status, error) return {"success": False, "error": f"API error {resp.status}: {error}"} except aiohttp.ClientError as e: logger.error("Tweet post network error: %s", e) return {"success": False, "error": f"Network error: {e}"} async def post_thread(tweets: list[str]) -> list[dict]: """Post a thread (multiple tweets in reply chain). Returns list of post results, one per tweet. """ results = [] reply_to = None for i, text in enumerate(tweets): result = await post_tweet(text, reply_to_id=reply_to) results.append(result) if not result["success"]: logger.error("Thread posting failed at tweet %d/%d: %s", i + 1, len(tweets), result["error"]) break reply_to = result.get("tweet_id") return results # ─── Post-Approval Hook ───────────────────────────────────────────── async def handle_approved_tweet( conn: sqlite3.Connection, request_id: int, ) -> dict: """Called when a tweet is approved. Posts to X and records the result. Returns the post result dict. """ row = conn.execute( "SELECT * FROM approval_queue WHERE id = ? AND type = 'tweet'", (request_id,), ).fetchone() if not row: return {"success": False, "error": f"Approval #{request_id} not found"} if row["status"] != "approved": return {"success": False, "error": f"Approval #{request_id} status is {row['status']}, not approved"} content = row["content"] ctx = json.loads(row["context"]) if row["context"] else {} # Parse thread (tweets separated by ---) tweets = [t.strip() for t in content.split("\n---\n") if t.strip()] # Extract reply_to tweet ID from URL if present reply_to_id = None reply_to_url = ctx.get("reply_to_url", "") if reply_to_url: import re match = re.search(r"/status/(\d+)", reply_to_url) if match: reply_to_id = match.group(1) # Post if len(tweets) == 1: result = await post_tweet(tweets[0], reply_to_id=reply_to_id) results = [result] else: # For threads, first tweet may be a reply results = [] first = await post_tweet(tweets[0], reply_to_id=reply_to_id) results.append(first) if first["success"] and len(tweets) > 1: thread_results = await post_thread(tweets[1:]) # Fix: thread_results already posted independently, need to chain # Actually post_thread handles chaining. Let me re-do this. pass # Simpler: use post_thread for everything if it's a multi-tweet if len(tweets) > 1: results = await post_thread(tweets) # Record result success = all(r["success"] for r in results) if success: tweet_urls = [r.get("tweet_url", "") for r in results if r.get("tweet_url")] published_url = tweet_urls[0] if tweet_urls else "" conn.execute( """UPDATE approval_queue SET context = json_set(COALESCE(context, '{}'), '$.published_url', ?, '$.published_at', datetime('now'), '$.tweet_ids', ?) WHERE id = ?""", (published_url, json.dumps([r.get("tweet_id") for r in results]), request_id), ) conn.commit() logger.info("Tweet #%d published: %s", request_id, published_url) else: errors = [r.get("error", "unknown") for r in results if not r["success"]] conn.execute( """UPDATE approval_queue SET context = json_set(COALESCE(context, '{}'), '$.post_error', ?, '$.post_attempted_at', datetime('now')) WHERE id = ?""", ("; ".join(errors), request_id), ) conn.commit() logger.error("Tweet #%d post failed: %s", request_id, errors) return results[0] if len(results) == 1 else {"success": success, "results": results}