Pulled from /opt/teleo-eval/telegram/ on VPS. Includes: - bot.py (92K), kb_retrieval.py, kb_tools.py (agentic retrieval) - retrieval.py (RRF merge, query decomposition, entity traversal) - response.py (system prompt builder, response parser) - agent_config.py, agent_runner.py (multi-agent template unit support) - approval_stages.py, approvals.py, digest.py (approval workflow) - eval_checks.py, eval.py (response quality checks) - output_gate.py, x_publisher.py, x_client.py, x_search.py (X pipeline) - market_data.py, worktree_lock.py (utilities) - rio.yaml, theseus.yaml (agent configs) These files were deployed to VPS but never committed to the repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
347 lines
12 KiB
Python
347 lines
12 KiB
Python
"""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}
|