teleo-codex/ops/pipeline-v2/telegram/x_publisher.py
m3taversal 7bfce6b706 commit telegram bot module from VPS — 20 files never previously in repo
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>
2026-04-13 11:02:32 +02:00

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}