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>
344 lines
13 KiB
Python
344 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.
|
|
"""
|
|
|
|
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)
|