Merge pull request #25 from living-ip/codex/leo-telegram-auto-resume-20260630
Some checks are pending
CI / lint-and-test (push) Waiting to run
Some checks are pending
CI / lint-and-test (push) Waiting to run
Auto-resume Leo paid research in Telegram
This commit is contained in:
commit
29429d6385
5 changed files with 236 additions and 5 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
161
telegram/bot.py
161
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
Loading…
Reference in a new issue