"""Telegram approval workflow — human-in-the-loop for outgoing comms + core KB changes. Flow: Agent submits → Leo reviews substance → Bot sends to Cory → Cory approves/rejects. Architecture: - approval_queue table in pipeline.db (migration v11) - Bot polls for leo_approved items, sends formatted Telegram messages with inline buttons - Cory taps Approve/Reject → callback handler updates status - 24h expiry timeout on all pending approvals OPSEC: Content filter rejects submissions containing financial figures or deal-specific language. No deal terms, no dollar amounts, no private investment details in approval requests — ever. Epimetheus owns this module. """ from __future__ import annotations # ruff: noqa: I001 import logging import re import sqlite3 from pathlib import Path try: from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import CallbackQueryHandler, ContextTypes except ImportError: # Optional in local unit tests that only exercise OPSEC logic. InlineKeyboardButton = None InlineKeyboardMarkup = None Update = None CallbackQueryHandler = None ContextTypes = None logger = logging.getLogger("telegram.approvals") # ─── OPSEC Content Filter ───────────────────────────────────────────── # Reject submissions containing financial figures or deal-specific language. # Pattern matches: $1M, $500K, 1.5 million, deal terms, valuation, cap table, etc. OPSEC_PATTERNS = [ re.compile(r"\$[\d,.]+[KMBkmb]?\b", re.IGNORECASE), # $500K, $1.5M, $100 re.compile(r"\b\d+[\d,.]*\s*(million|billion|thousand)\b", re.IGNORECASE), re.compile(r"\b(deal terms?|valuation|cap table|equity split|ownership stake|term sheet|dilution|fee split)\b", re.IGNORECASE), re.compile(r"\b(SAFE\s+(?:note|round|agreement)|SAFT|convertible note|preferred stock|liquidation preference)\b", re.IGNORECASE), re.compile(r"\bSeries\s+[A-Z]\b", re.IGNORECASE), # Series A/B/C/F funding rounds re.compile(r"\b(partnership terms|committed to (?:the |a )?round|funding round|(?:pre-?)?seed round)\b", re.IGNORECASE), ] # Sensitive entity names — loaded from opsec-entities.txt config file. # Edit the config file to add/remove entities without code changes. _OPSEC_ENTITIES_FILE = Path(__file__).parent / "opsec-entities.txt" def _load_sensitive_entities() -> list[re.Pattern]: """Load sensitive entity patterns from config file.""" patterns = [] if _OPSEC_ENTITIES_FILE.exists(): for line in _OPSEC_ENTITIES_FILE.read_text().splitlines(): line = line.strip() if line and not line.startswith("#"): patterns.append(re.compile(rf"\b{line}\b", re.IGNORECASE)) return patterns SENSITIVE_ENTITIES = _load_sensitive_entities() def check_opsec(content: str) -> str | None: """Check content against OPSEC patterns. Returns violation description or None.""" for pattern in OPSEC_PATTERNS: match = pattern.search(content) if match: return f"OPSEC violation: content contains '{match.group()}' — no financial figures or deal terms in approval requests" for pattern in SENSITIVE_ENTITIES: match = pattern.search(content) if match: return f"OPSEC violation: content references sensitive entity '{match.group()}' — deal-adjacent entities blocked" return None # ─── Message Formatting ─────────────────────────────────────────────── TYPE_LABELS = { "tweet": "Tweet", "kb_change": "KB Change", "architecture_change": "Architecture Change", "public_post": "Public Post", "position": "Position", "agent_structure": "Agent Structure", } # ─── Tier Classification ───────────────────────────────────────────── # Tier 1: Must approve (outgoing, public, irreversible) # Tier 2: Should approve (core architecture, strategic) # Tier 3: Autonomous (no approval needed — goes to daily digest only) TIER_1_TYPES = {"tweet", "public_post", "position"} TIER_2_TYPES = {"kb_change", "architecture_change", "agent_structure"} # Everything else is Tier 3 — no approval queue entry, digest only def classify_tier(approval_type: str) -> int: """Classify an approval request into tier 1, 2, or 3.""" if approval_type in TIER_1_TYPES: return 1 if approval_type in TIER_2_TYPES: return 2 return 3 def format_approval_message(row: sqlite3.Row) -> str: """Format an approval request for Telegram display.""" type_label = TYPE_LABELS.get(row["type"], row["type"].replace("_", " ").title()) agent = row["originating_agent"].title() content = row["content"] # Truncate long content for Telegram (4096 char limit) if len(content) > 3000: content = content[:3000] + "\n\n[... truncated]" parts = [ "APPROVAL REQUEST", "", f"Type: {type_label}", f"From: {agent}", ] if row["context"]: parts.append(f"Context: {row['context']}") if row["leo_review_note"]: parts.append(f"Leo review: {row['leo_review_note']}") parts.extend([ "", "---", content, "---", ]) return "\n".join(parts) def build_keyboard(request_id: int) -> InlineKeyboardMarkup: """Build inline keyboard with Approve/Reject buttons.""" if InlineKeyboardMarkup is None or InlineKeyboardButton is None: raise ImportError("python-telegram-bot is required to build approval keyboards") return InlineKeyboardMarkup([ [ InlineKeyboardButton("Approve", callback_data=f"approve:{request_id}"), InlineKeyboardButton("Reject", callback_data=f"reject:{request_id}"), ] ]) # ─── Core Logic ─────────────────────────────────────────────────────── def get_pending_for_cory(conn: sqlite3.Connection) -> list[sqlite3.Row]: """Get approval requests that Leo approved and are ready for Cory.""" return conn.execute( """SELECT * FROM approval_queue WHERE leo_review_status = 'leo_approved' AND status = 'pending' AND telegram_message_id IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) ORDER BY submitted_at ASC""", ).fetchall() def expire_stale_requests(conn: sqlite3.Connection) -> int: """Expire requests older than 24h. Returns count expired.""" cursor = conn.execute( """UPDATE approval_queue SET status = 'expired', decided_at = datetime('now') WHERE status = 'pending' AND expires_at IS NOT NULL AND expires_at <= datetime('now')""", ) if cursor.rowcount > 0: conn.commit() logger.info("Expired %d stale approval requests", cursor.rowcount) return cursor.rowcount def record_decision( conn: sqlite3.Connection, request_id: int, decision: str, decision_by: str, rejection_reason: str = None, ) -> bool: """Record an approval/rejection decision. Returns True if updated.""" cursor = conn.execute( """UPDATE approval_queue SET status = ?, decision_by = ?, rejection_reason = ?, decided_at = datetime('now') WHERE id = ? AND status = 'pending'""", (decision, decision_by, rejection_reason, request_id), ) conn.commit() return cursor.rowcount > 0 def record_telegram_message(conn: sqlite3.Connection, request_id: int, message_id: int): """Record the Telegram message ID for an approval notification.""" conn.execute( "UPDATE approval_queue SET telegram_message_id = ? WHERE id = ?", (message_id, request_id), ) conn.commit() # ─── Telegram Handlers ──────────────────────────────────────────────── async def handle_approval_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle Approve/Reject button taps from Cory.""" query = update.callback_query await query.answer() data = query.data if not data or ":" not in data: return action, request_id_str = data.split(":", 1) if action not in ("approve", "reject"): return try: request_id = int(request_id_str) except ValueError: return conn = context.bot_data.get("approval_conn") if not conn: await query.edit_message_text("Error: approval DB not connected") return if action == "reject": # For rejection, edit the message to ask for reason row = conn.execute( "SELECT * FROM approval_queue WHERE id = ?", (request_id,) ).fetchone() if not row or row["status"] != "pending": await query.edit_message_text("This request has already been processed.") return # Store pending rejection — user can reply with reason context.bot_data[f"pending_reject:{request_id}"] = True await query.edit_message_text( f"{query.message.text}\n\nRejected. Reply to this message with feedback for the agent (optional).", ) record_decision(conn, request_id, "rejected", query.from_user.username or str(query.from_user.id)) logger.info("Approval #%d REJECTED by %s", request_id, query.from_user.username) return # Approve user = query.from_user.username or str(query.from_user.id) success = record_decision(conn, request_id, "approved", user) if success: # Check if this is a tweet — if so, auto-post to X row = conn.execute( "SELECT type FROM approval_queue WHERE id = ?", (request_id,) ).fetchone() post_status = "" if row and row["type"] == "tweet": try: from x_publisher import handle_approved_tweet result = await handle_approved_tweet(conn, request_id) if result.get("success"): url = result.get("tweet_url", "") post_status = f"\n\nPosted to X: {url}" logger.info("Tweet #%d auto-posted: %s", request_id, url) else: error = result.get("error", "unknown error") post_status = f"\n\nPost failed: {error}" logger.error("Tweet #%d auto-post failed: %s", request_id, error) except Exception as e: post_status = f"\n\nPost failed: {e}" logger.error("Tweet #%d auto-post error: %s", request_id, e) await query.edit_message_text( f"{query.message.text}\n\nAPPROVED by {user}{post_status}" ) logger.info("Approval #%d APPROVED by %s", request_id, user) else: await query.edit_message_text("This request has already been processed.") async def handle_rejection_reply(update: Update, context: ContextTypes.DEFAULT_TYPE): """Capture rejection reason from reply to a rejected approval message.""" if not update.message or not update.message.reply_to_message: return False # Check if the replied-to message is a rejected approval conn = context.bot_data.get("approval_conn") if not conn: return False reply_msg_id = update.message.reply_to_message.message_id row = conn.execute( "SELECT id FROM approval_queue WHERE telegram_message_id = ? AND status = 'rejected'", (reply_msg_id,), ).fetchone() if not row: return False # Update rejection reason reason = update.message.text.strip() conn.execute( "UPDATE approval_queue SET rejection_reason = ? WHERE id = ?", (reason, row["id"]), ) conn.commit() await update.message.reply_text(f"Feedback recorded for approval #{row['id']}.") logger.info("Rejection reason added for approval #%d: %s", row["id"], reason[:100]) return True # ─── Poll Job ───────────────────────────────────────────────────────── async def poll_approvals(context: ContextTypes.DEFAULT_TYPE): """Poll for Leo-approved requests and send to Cory. Runs every 30s.""" conn = context.bot_data.get("approval_conn") admin_chat_id = context.bot_data.get("admin_chat_id") if not conn or not admin_chat_id: return # Expire stale requests first (may fail on DB lock - retry next cycle) try: expire_stale_requests(conn) except Exception: pass # non-fatal, retries in 30s # Send new notifications pending = get_pending_for_cory(conn) for row in pending: try: text = format_approval_message(row) keyboard = build_keyboard(row["id"]) msg = await context.bot.send_message( chat_id=admin_chat_id, text=text, reply_markup=keyboard, ) record_telegram_message(conn, row["id"], msg.message_id) logger.info("Sent approval #%d to admin (type=%s, agent=%s)", row["id"], row["type"], row["originating_agent"]) except Exception as e: logger.error("Failed to send approval #%d: %s", row["id"], e)