From 85e1f78b97e8ced39e1e40e3d866c2da7480c919 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Tue, 30 Jun 2026 20:05:28 +0200 Subject: [PATCH] Auto-resume Leo paid research in Telegram --- telegram/agents/leo-wallet-test.yaml | 8 +- telegram/agents/leo.yaml | 6 +- telegram/bot.py | 161 +++++++++++++++++++++++++ telegram/http_chat_proxy.py | 29 +++++ tests/test_telegram_leo_x402_bridge.py | 37 ++++++ 5 files changed, 236 insertions(+), 5 deletions(-) diff --git a/telegram/agents/leo-wallet-test.yaml b/telegram/agents/leo-wallet-test.yaml index 910acf8..08e39ae 100644 --- a/telegram/agents/leo-wallet-test.yaml +++ b/telegram/agents/leo-wallet-test.yaml @@ -43,9 +43,11 @@ voice_definition: | unless the hosted Leo HTTP route returns retained payment/readback evidence. Ordinary addressed/private chat may be routed into smart research when the request clearly asks for sourced, current, market, or evidence-backed work. - Explicit /smart_research remains available for narrow canaries. Paid smart - research remains fail-closed unless the server-side allow flag, allowed chat - id, cap, and retained approval-ref file are all present. + Explicit /smart_research remains available for narrow canaries. Quote-first + paid research should auto-resume in the same Telegram chat after the settled + work order is visible; pasted work_order_id values are fallback/debug only. + Paid smart research remains fail-closed unless the server-side allow flag, + allowed chat id, cap, and retained approval-ref file are all present. Do not request or expose private keys, bot tokens, wallet exports, seed phrases, or raw secret values. diff --git a/telegram/agents/leo.yaml b/telegram/agents/leo.yaml index 9b35588..2edafa7 100644 --- a/telegram/agents/leo.yaml +++ b/telegram/agents/leo.yaml @@ -51,8 +51,10 @@ voice_definition: | payment/readback evidence. When addressed or used in private chat, clear requests for fresh sourced research should go through Leo's hosted research route. First return a paid - research quote and checkout link. Only resume paid execution after a - work_order_id or payment receipt is present. + research quote and checkout link. After payment, continue automatically in + the same Telegram chat when the settled work order is visible. Treat a + work_order_id or payment receipt pasted by the user as a fallback/debug path, + not the normal UX. # ─── Learnings ─────────────────────────────────────────────────────────── learnings_file: agents/leo/learnings.md diff --git a/telegram/bot.py b/telegram/bot.py index 2d4a053..280792d 100644 --- a/telegram/bot.py +++ b/telegram/bot.py @@ -55,6 +55,7 @@ from http_chat_proxy import ( DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES, build_chat_proxy_payload, build_smart_research_proxy_payload, + classify_smart_research_auto_resume_response, extract_auto_smart_research_followup_goal, extract_auto_smart_research_goal, extract_paid_work_order_id, @@ -101,6 +102,7 @@ AGENT_RESPOND_TO_PRIVATE_CHATS = False AGENT_MENTION_ALIASES = ["@teleo", "@FutAIrdBot"] AGENT_SMART_RESEARCH_COMMAND_PREFIXES = list(DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES) AGENT_AUTO_SMART_RESEARCH_FROM_CHAT = False +smart_research_auto_resume_tasks: set[tuple[int, int]] = set() # Rate limits MAX_RESPONSE_PER_USER_PER_HOUR = 30 @@ -748,6 +750,145 @@ def _smart_research_payment_gate(chat_id: int) -> dict: } +def _smart_research_auto_resume_enabled() -> bool: + return os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_AUTO_RESUME", "1") != "0" + + +def _smart_research_auto_resume_attempts() -> int: + try: + attempts = int(os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_AUTO_RESUME_ATTEMPTS", "24")) + except ValueError: + return 24 + return min(max(attempts, 1), 120) + + +def _smart_research_auto_resume_interval_seconds() -> float: + try: + interval = float(os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_AUTO_RESUME_INTERVAL_SECONDS", "10")) + except ValueError: + return 10.0 + return min(max(interval, 2.0), 120.0) + + +def _should_start_smart_research_auto_resume_poll( + *, + paid_work_order_id: str | None, + http_status: int, + proxy_body: dict, +) -> bool: + if paid_work_order_id or not _smart_research_auto_resume_enabled(): + return False + if not isinstance(proxy_body, dict): + return False + if proxy_body.get("status") != "payment_authorization_required": + return False + checkout = proxy_body.get("checkout") + return isinstance(checkout, dict) and bool(checkout.get("checkoutUrl") or checkout.get("checkout_url")) + + +async def _poll_smart_research_auto_resume( + msg, + context: ContextTypes.DEFAULT_TYPE, + *, + research_goal: str, + username: str | None, +) -> None: + """Poll Leo's chat-bound research route until the x402 work order is visible.""" + if not AGENT_HTTP_RESEARCH_PROXY_URL: + return + key = (msg.chat_id, msg.message_id) + if key in smart_research_auto_resume_tasks: + return + smart_research_auto_resume_tasks.add(key) + attempts = _smart_research_auto_resume_attempts() + interval = _smart_research_auto_resume_interval_seconds() + try: + for attempt in range(attempts): + await asyncio.sleep(interval) + payload = build_smart_research_proxy_payload( + research_goal=research_goal, + source="telegram", + agent=AGENT_NAME.lower(), + chat_id=msg.chat_id, + message_id=msg.message_id, + username=username, + include_synthesis=True, + ) + try: + status, proxy_body, proxy_reply = await post_chat_proxy( + url=AGENT_HTTP_RESEARCH_PROXY_URL, + payload=payload, + timeout_seconds=90, + ) + except Exception as exc: + logger.info( + "%s smart research auto-resume poll %d/%d failed: %s", + AGENT_NAME, + attempt + 1, + attempts, + exc, + ) + continue + + classification = classify_smart_research_auto_resume_response(status, proxy_body) + if classification == "pending": + continue + if classification == "ready" and proxy_reply: + await _reply_text_native(msg, proxy_reply, do_quote=True) + if msg.from_user: + entry = { + "user": research_goal[:500], + "bot": proxy_reply[:500], + "username": username or "anonymous", + } + user_key = (msg.chat_id, msg.from_user.id) + history = conversation_history.setdefault(user_key, []) + history.append(entry) + if len(history) > MAX_HISTORY_USER: + history.pop(0) + chat_key = (msg.chat_id, 0) + chat_history = conversation_history.setdefault(chat_key, []) + chat_history.append(entry) + if len(chat_history) > MAX_HISTORY_CHAT: + chat_history.pop(0) + user_response_times[msg.from_user.id].append(time.time()) + _record_transcript( + msg, + proxy_reply, + is_bot=True, + rio_response=proxy_reply, + internal={ + "agent": AGENT_NAME.lower(), + "http_research_proxy_auto_resume": True, + "http_status": status, + "schema": proxy_body.get("schema") if isinstance(proxy_body, dict) else None, + }, + ) + logger.info( + "%s auto-resumed paid smart research in Telegram chat_id=%s message_id=%s", + AGENT_NAME, + msg.chat_id, + msg.message_id, + ) + return + logger.info( + "%s smart research auto-resume poll stopped on classification=%s status=%s", + AGENT_NAME, + classification, + status, + ) + return + logger.info( + "%s smart research auto-resume poll expired chat_id=%s message_id=%s attempts=%s", + AGENT_NAME, + msg.chat_id, + msg.message_id, + attempts, + ) + finally: + smart_research_auto_resume_tasks.discard(key) + + async def _market_context_for_message( text: str, extra_terms: list[str] | tuple[str, ...] = (), @@ -1279,6 +1420,26 @@ async def handle_tagged(update: Update, context: ContextTypes.DEFAULT_TYPE): await _reply_text_native(msg, proxy_reply, do_quote=True) + if _should_start_smart_research_auto_resume_poll( + paid_work_order_id=paid_work_order_id, + http_status=status, + proxy_body=proxy_body, + ): + context.application.create_task( + _poll_smart_research_auto_resume( + msg, + context, + research_goal=smart_research_goal, + username=user.username if user else None, + ) + ) + logger.info( + "%s started smart research auto-resume poll chat_id=%s message_id=%s", + AGENT_NAME, + msg.chat_id, + msg.message_id, + ) + if user: username = user.username or "anonymous" key = (msg.chat_id, user.id) diff --git a/telegram/http_chat_proxy.py b/telegram/http_chat_proxy.py index 3587b5c..481af11 100644 --- a/telegram/http_chat_proxy.py +++ b/telegram/http_chat_proxy.py @@ -197,6 +197,35 @@ def smart_research_payment_fields_for_message( return {"allow_paid_execution": False} +def classify_smart_research_auto_resume_response( + http_status: int, + payload: dict[str, Any] | None, +) -> str: + """Classify a poll response for Telegram auto-resume. + + Returns: + - pending: the payment/work order is not visible yet; keep polling silently. + - ready: the route has a final user-facing reply or a visible paid-work-order blocker. + - ignore: the response is unrelated to an auto-resume poll. + """ + if not isinstance(payload, dict): + return "ignore" + status = str(payload.get("status") or "").strip() + auto_resume = payload.get("autoResume") + paid_work_order = payload.get("paidWorkOrder") + auto_activated = isinstance(auto_resume, dict) and auto_resume.get("activated") is True + work_order_settled = ( + isinstance(paid_work_order, dict) and paid_work_order.get("status") == "settled" + ) + if status == "payment_authorization_required" and not auto_activated and not work_order_settled: + return "pending" + if auto_activated or work_order_settled: + return "ready" + if http_status < 400 and status and status != "payment_authorization_required": + return "ready" + return "ignore" + + def build_smart_research_proxy_payload( *, research_goal: str, diff --git a/tests/test_telegram_leo_x402_bridge.py b/tests/test_telegram_leo_x402_bridge.py index 96e424d..62e2da6 100644 --- a/tests/test_telegram_leo_x402_bridge.py +++ b/tests/test_telegram_leo_x402_bridge.py @@ -13,6 +13,7 @@ from agent_config import load_agent_config # noqa: E402 from http_chat_proxy import ( # noqa: E402 build_chat_proxy_payload, build_smart_research_proxy_payload, + classify_smart_research_auto_resume_response, extract_auto_smart_research_followup_goal, extract_auto_smart_research_goal, extract_paid_work_order_id, @@ -169,6 +170,42 @@ def test_smart_research_payment_fields_resume_uses_capped_gate(): assert payment_fields == configured_gate +@pytest.mark.parametrize( + ("http_status", "payload", "expected"), + [ + ( + 402, + { + "status": "payment_authorization_required", + "autoResume": {"activated": False}, + }, + "pending", + ), + ( + 200, + { + "status": "source_only_done", + "autoResume": {"activated": True}, + "paidWorkOrder": {"status": "settled"}, + }, + "ready", + ), + ( + 402, + { + "status": "payment_authorization_required", + "autoResume": {"activated": True}, + "paidWorkOrder": {"status": "settled"}, + }, + "ready", + ), + (500, {"error": "temporary"}, "ignore"), + ], +) +def test_classify_smart_research_auto_resume_response(http_status, payload, expected): + assert classify_smart_research_auto_resume_response(http_status, payload) == expected + + @pytest.mark.parametrize( ("message", "expected"), [