Auto-resume Leo paid research in Telegram
This commit is contained in:
parent
06a8b547ab
commit
85e1f78b97
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.
|
unless the hosted Leo HTTP route returns retained payment/readback evidence.
|
||||||
Ordinary addressed/private chat may be routed into smart research when the
|
Ordinary addressed/private chat may be routed into smart research when the
|
||||||
request clearly asks for sourced, current, market, or evidence-backed work.
|
request clearly asks for sourced, current, market, or evidence-backed work.
|
||||||
Explicit /smart_research remains available for narrow canaries. Paid smart
|
Explicit /smart_research remains available for narrow canaries. Quote-first
|
||||||
research remains fail-closed unless the server-side allow flag, allowed chat
|
paid research should auto-resume in the same Telegram chat after the settled
|
||||||
id, cap, and retained approval-ref file are all present.
|
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,
|
Do not request or expose private keys, bot tokens, wallet exports, seed phrases,
|
||||||
or raw secret values.
|
or raw secret values.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,10 @@ voice_definition: |
|
||||||
payment/readback evidence.
|
payment/readback evidence.
|
||||||
When addressed or used in private chat, clear requests for fresh sourced
|
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 should go through Leo's hosted research route. First return a paid
|
||||||
research quote and checkout link. Only resume paid execution after a
|
research quote and checkout link. After payment, continue automatically in
|
||||||
work_order_id or payment receipt is present.
|
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 ───────────────────────────────────────────────────────────
|
||||||
learnings_file: agents/leo/learnings.md
|
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,
|
DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES,
|
||||||
build_chat_proxy_payload,
|
build_chat_proxy_payload,
|
||||||
build_smart_research_proxy_payload,
|
build_smart_research_proxy_payload,
|
||||||
|
classify_smart_research_auto_resume_response,
|
||||||
extract_auto_smart_research_followup_goal,
|
extract_auto_smart_research_followup_goal,
|
||||||
extract_auto_smart_research_goal,
|
extract_auto_smart_research_goal,
|
||||||
extract_paid_work_order_id,
|
extract_paid_work_order_id,
|
||||||
|
|
@ -101,6 +102,7 @@ AGENT_RESPOND_TO_PRIVATE_CHATS = False
|
||||||
AGENT_MENTION_ALIASES = ["@teleo", "@FutAIrdBot"]
|
AGENT_MENTION_ALIASES = ["@teleo", "@FutAIrdBot"]
|
||||||
AGENT_SMART_RESEARCH_COMMAND_PREFIXES = list(DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES)
|
AGENT_SMART_RESEARCH_COMMAND_PREFIXES = list(DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES)
|
||||||
AGENT_AUTO_SMART_RESEARCH_FROM_CHAT = False
|
AGENT_AUTO_SMART_RESEARCH_FROM_CHAT = False
|
||||||
|
smart_research_auto_resume_tasks: set[tuple[int, int]] = set()
|
||||||
|
|
||||||
# Rate limits
|
# Rate limits
|
||||||
MAX_RESPONSE_PER_USER_PER_HOUR = 30
|
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(
|
async def _market_context_for_message(
|
||||||
text: str,
|
text: str,
|
||||||
extra_terms: list[str] | tuple[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)
|
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:
|
if user:
|
||||||
username = user.username or "anonymous"
|
username = user.username or "anonymous"
|
||||||
key = (msg.chat_id, user.id)
|
key = (msg.chat_id, user.id)
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,35 @@ def smart_research_payment_fields_for_message(
|
||||||
return {"allow_paid_execution": False}
|
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(
|
def build_smart_research_proxy_payload(
|
||||||
*,
|
*,
|
||||||
research_goal: str,
|
research_goal: str,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from agent_config import load_agent_config # noqa: E402
|
||||||
from http_chat_proxy import ( # noqa: E402
|
from http_chat_proxy import ( # noqa: E402
|
||||||
build_chat_proxy_payload,
|
build_chat_proxy_payload,
|
||||||
build_smart_research_proxy_payload,
|
build_smart_research_proxy_payload,
|
||||||
|
classify_smart_research_auto_resume_response,
|
||||||
extract_auto_smart_research_followup_goal,
|
extract_auto_smart_research_followup_goal,
|
||||||
extract_auto_smart_research_goal,
|
extract_auto_smart_research_goal,
|
||||||
extract_paid_work_order_id,
|
extract_paid_work_order_id,
|
||||||
|
|
@ -169,6 +170,42 @@ def test_smart_research_payment_fields_resume_uses_capped_gate():
|
||||||
assert payment_fields == configured_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(
|
@pytest.mark.parametrize(
|
||||||
("message", "expected"),
|
("message", "expected"),
|
||||||
[
|
[
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue