"""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. """ import logging import re import sqlite3 from datetime import datetime, timezone from pathlib import Path from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import CallbackQueryHandler, ContextTypes 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 = [ f"APPROVAL REQUEST", f"", 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.""" 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": # Check if user sent a reply with rejection reason rejection_reason = None # 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)