teleo-infrastructure/telegram/approvals.py
2026-06-22 20:33:02 +02:00

354 lines
13 KiB
Python

"""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)