From b769eb68b6ca7ab7aecf4cbdf7979bfc3c56a9c2 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Fri, 19 Jun 2026 19:27:12 +0200 Subject: [PATCH] Add Leo Telegram x402 bridge --- deploy/auto-deploy.sh | 2 + deploy/deploy.sh | 2 + .../telegram-leo-x402-bridge-proof.json | 21 +++ docs/telegram-leo-x402-bridge-pr-packet.md | 83 +++++++++++ docs/telegram-leo-x402-priority-spec.md | 133 ++++++++++++++++++ scripts/check_telegram_leo_x402_bridge.py | 88 ++++++++++++ telegram/agent_config.py | 21 ++- telegram/agents/leo.yaml | 57 ++++++++ telegram/bot.py | 124 +++++++++++++++- telegram/http_chat_proxy.py | 80 +++++++++++ tests/test_telegram_leo_x402_bridge.py | 87 ++++++++++++ 11 files changed, 692 insertions(+), 6 deletions(-) create mode 100644 docs/reports/telegram-leo-x402-bridge-proof.json create mode 100644 docs/telegram-leo-x402-bridge-pr-packet.md create mode 100644 docs/telegram-leo-x402-priority-spec.md create mode 100644 scripts/check_telegram_leo_x402_bridge.py create mode 100644 telegram/agents/leo.yaml create mode 100644 telegram/http_chat_proxy.py create mode 100644 tests/test_telegram_leo_x402_bridge.py diff --git a/deploy/auto-deploy.sh b/deploy/auto-deploy.sh index 46ebcf9..94b68fd 100755 --- a/deploy/auto-deploy.sh +++ b/deploy/auto-deploy.sh @@ -13,6 +13,7 @@ fi DEPLOY_CHECKOUT="/opt/teleo-eval/workspaces/deploy-infra" PIPELINE_DIR="/opt/teleo-eval/pipeline" +TELEGRAM_DIR="/opt/teleo-eval/telegram" DIAGNOSTICS_DIR="/opt/teleo-eval/diagnostics" AGENT_STATE_DIR="/opt/teleo-eval/ops/agent-state" STAMP_FILE="/opt/teleo-eval/.last-deploy-sha" @@ -74,6 +75,7 @@ for f in teleo-pipeline.py reweave.py fetch_coins.py pipeline-health-check.py; d done rsync "${RSYNC_OPTS[@]}" telegram/ "$PIPELINE_DIR/telegram/" +rsync "${RSYNC_OPTS[@]}" telegram/ "$TELEGRAM_DIR/" rsync "${RSYNC_OPTS[@]}" diagnostics/ "$DIAGNOSTICS_DIR/" rsync "${RSYNC_OPTS[@]}" agent-state/ "$AGENT_STATE_DIR/" rsync "${RSYNC_OPTS[@]}" tests/ "$PIPELINE_DIR/tests/" diff --git a/deploy/deploy.sh b/deploy/deploy.sh index f6abeed..d42135b 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -7,6 +7,7 @@ set -euo pipefail VPS_HOST="teleo@77.42.65.182" VPS_PIPELINE="/opt/teleo-eval/pipeline" +VPS_TELEGRAM="/opt/teleo-eval/telegram" VPS_DIAGNOSTICS="/opt/teleo-eval/diagnostics" VPS_AGENT_STATE="/opt/teleo-eval/ops/agent-state" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -74,6 +75,7 @@ echo "" echo "=== Telegram bot ===" rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/telegram/" "$VPS_HOST:$VPS_PIPELINE/telegram/" +rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/telegram/" "$VPS_HOST:$VPS_TELEGRAM/" echo "" echo "=== Tests ===" diff --git a/docs/reports/telegram-leo-x402-bridge-proof.json b/docs/reports/telegram-leo-x402-bridge-proof.json new file mode 100644 index 0000000..bfb1148 --- /dev/null +++ b/docs/reports/telegram-leo-x402-bridge-proof.json @@ -0,0 +1,21 @@ +{ + "agent": "leo", + "currentTier": "T3_live_readonly", + "generatedAt": "2026-06-19T17:25:27.555494+00:00", + "httpStatus": 402, + "llmOk": true, + "notProven": [ + "teleo-agent@leo.service active", + "Telegram message delivery", + "Telegram reply delivery", + "new payment execution" + ], + "ok": true, + "reply": "This reached Leo HTTP via Telegram bridge confirmation.", + "requiredTier": "T3_live_readonly", + "routeSchema": "livingip.x402.leoChatResponse.v1", + "schema": "livingip.telegramLeoX402BridgeProof.v1", + "secretValuesIncluded": false, + "strongestClaimAllowed": "Telegram bridge helper can POST a no-secret payload to the public Leo HTTP chat route and extract a usable Leo reply. This proves the bridge parser/readback only; it does not prove the Telegram bot service is deployed or active.", + "url": "https://leo.livingip.xyz/api/agents/leo/chat" +} diff --git a/docs/telegram-leo-x402-bridge-pr-packet.md b/docs/telegram-leo-x402-bridge-pr-packet.md new file mode 100644 index 0000000..5d51947 --- /dev/null +++ b/docs/telegram-leo-x402-bridge-pr-packet.md @@ -0,0 +1,83 @@ +# Telegram Leo x402 Bridge PR Packet + +## Working Target + +Run Leo as a Telegram bot without duplicating Leo/x402 logic: Telegram receives +a user message, forwards it to `https://leo.livingip.xyz/api/agents/leo/chat`, +and replies with the hosted Leo answer. + +## Non-Destructive Boundary + +- This PR does not start, stop, restart, or mutate any live Telegram service. +- Deployment sync is updated to copy `telegram/` into both + `/opt/teleo-eval/pipeline/telegram/` and `/opt/teleo-eval/telegram/`, matching + the current `teleo-agent@.service` runtime path. +- Existing Rio and Theseus configs do not set `http_chat_proxy_url`, so their + current KB/retrieval path stays unchanged. +- Leo opts into the bridge with `telegram/agents/leo.yaml`. +- The live token's Telegram username readback is `@TeleoHumanBot`; `@teLEOhuman` + remains an alias for continuity with Leo's X identity. +- Secret contents are not stored or printed. The config references only the + expected token-file name: `leo-telegram-bot-token`. + +## Local Proof Commands + +```sh +.venv/bin/python -m pytest tests/test_telegram_leo_x402_bridge.py +.venv/bin/python -m py_compile telegram/agent_config.py telegram/http_chat_proxy.py telegram/bot.py telegram/agent_runner.py +.venv/bin/python telegram/agent_runner.py --agent leo --validate +.venv/bin/python scripts/check_telegram_leo_x402_bridge.py +bash -n deploy/deploy.sh deploy/auto-deploy.sh +git diff --check +``` + +Primary retained proof path: + +```text +docs/reports/telegram-leo-x402-bridge-proof.json +``` + +## Production Promotion Commands + +Run only after review and after confirming the token filename exists on the VPS: + +```sh +test -f /opt/teleo-eval/secrets/leo-telegram-bot-token +test -f /opt/teleo-eval/telegram/agents/leo.yaml +test -f /opt/teleo-eval/telegram/http_chat_proxy.py +/opt/teleo-eval/pipeline/.venv/bin/python3 /opt/teleo-eval/telegram/agent_runner.py --agent leo --validate +systemctl start teleo-agent@leo +journalctl -u teleo-agent@leo -n 100 --no-pager +``` + +Then send Leo a Telegram DM or tag the configured handle and retain: + +- Telegram message/reply screenshot or export. +- `journalctl -u teleo-agent@leo` lines showing the proxy path. +- Caddy access log line for `POST /api/agents/leo/chat` on `leo.livingip.xyz`. + +## Reviewer CTA + +Approve deploying this as the next non-destructive Telegram step if these facts +are acceptable: + +- `leo-telegram-bot-token` exists on the VPS. +- Telegram `getMe` for that token reports bot username `TeleoHumanBot`. +- `teleo-agent@leo.service` is currently inactive, so this is an additive new + agent process rather than a restart of Rio or Theseus. +- The public Leo HTTP route already returns a parseable Leo reply. +- Existing Rio/Theseus configs do not set `http_chat_proxy_url`. +- The deploy-path mismatch is fixed by syncing Telegram files to the runtime + path used by `teleo-agent@.service`. + +## Strongest Claim Before Promotion + +PR-ready local bridge only: config and parser tests prove Telegram can be wired +to the hosted Leo HTTP route without changing existing Rio/Theseus behavior. + +## Strongest Claim After Promotion + +If the production commands pass and a Telegram message returns a hosted Leo +answer, Telegram Leo is a live transport for Leo's public HTTP chat route. +Payment and external research claims still come from retained HTTP/x402 proof +artifacts, not from Telegram by itself. diff --git a/docs/telegram-leo-x402-priority-spec.md b/docs/telegram-leo-x402-priority-spec.md new file mode 100644 index 0000000..7a1c7a6 --- /dev/null +++ b/docs/telegram-leo-x402-priority-spec.md @@ -0,0 +1,133 @@ +# Telegram Leo x402 Priority And Spec + +## Definition Of Working + +Working target: a user can DM or tag `@TeleoHumanBot`; the Telegram Leo process +forwards the message to `https://leo.livingip.xyz/api/agents/leo/chat`; the user +receives a Leo answer; retained logs prove the request hit the public Leo HTTP +route. + +Operator path: + +```sh +/opt/teleo-eval/pipeline/.venv/bin/python3 /opt/teleo-eval/telegram/agent_runner.py --agent leo --validate +systemctl start teleo-agent@leo +journalctl -u teleo-agent@leo -n 100 --no-pager +``` + +Done means: + +- `teleo-agent@leo.service` is active on `77.42.65.182`. +- A real Telegram message to `@TeleoHumanBot` receives a Leo reply. +- Retained proof includes Telegram message/readback, `journalctl` proxy log, and + `leo.livingip.xyz` HTTP access/readback. +- Rio and Theseus remain unaffected. + +Not done: + +- HTTP-only proof without a live Telegram delivery. +- Candidate/local proof without the public bot service active. +- Payment evidence reused as Telegram delivery evidence. + +Required tier: `T3_live_readonly` for the Telegram transport; payment claims use +the separately retained x402/Faremeter/AgentCash evidence tiers. + +Current tier: `T3_live_readonly` for bridge-to-public-HTTP proof only. The bot +token exists on the VPS, `getMe` identifies `@TeleoHumanBot`, and temporary VPS +config validation passed. The live `teleo-agent@leo.service` deployment has not +been started by this PR-shaped patch. + +Promotion gate: current VPS readback showed `teleo-agent@leo.service` uses +`/opt/teleo-eval/telegram/agent_runner.py`, while deploy scripts historically +synced `telegram/` only into `/opt/teleo-eval/pipeline/telegram/`. This patch +updates both manual and auto deploy scripts to sync `telegram/` into the runtime +path too. Do not start `teleo-agent@leo` until `leo.yaml` and +`http_chat_proxy.py` read back from `/opt/teleo-eval/telegram/`. + +## Priority Matrix + +| Priority | Lane | Current State | Ship Decision | +| --- | --- | --- | --- | +| P0 | Telegram Leo bridge deploy/readback | PR-shaped patch exists; public HTTP proof is retained; VPS token and config validation are confirmed; deploy-path mismatch is patched locally. | Push/merge the bridge, confirm runtime files read back under `/opt/teleo-eval/telegram`, start `teleo-agent@leo`, and retain Telegram delivery logs. | +| P0 | Self-hosted Faremeter seller rail | Retained public and hosted mainnet canary receipts exist, and direct `77.42.65.182:3118` currently serves a valid 0.01 USDC mainnet challenge. Fresh `https://leo.livingip.xyz` readback currently returns a Devnet `payment_challenge_unavailable` response, so public host routing is not proving the mainnet Faremeter rail right now. | Keep Faremeter as the default seller rail, but repair/repoint public `leo.livingip.xyz` to the working mainnet route before claiming current public mainnet seller readiness. | +| P1 | Leo paid research outbound loop | AgentCash/StableEnrich paid answer and Leo analysis proof already exist. | Expose the result through Telegram after bridge deploy; add per-provider approval packets for new services. | +| P1 | Public Leo HTTP behavior | `https://leo.livingip.xyz/api/agents/leo/chat` returns a parseable Leo reply under the current schema. | Treat as the bridge backend; avoid duplicating Leo logic inside Telegram. | +| P2 | Corbits/Herd/payable external services | Corbits moved payment but failed upstream API-key validation; Herd still needs an authenticated/payable endpoint proof. | Keep as provider-specific follow-up; do not block Telegram/Faremeter shipping on it. | +| P2 | All inbound service coverage | Sponsor-research has the strongest retained x402 receipts; other catalog rows need per-service canaries. | Broaden after Telegram bridge is live. | + +## Spec Tickets + +### TLG-001: Merge And Deploy Telegram Leo Bridge + +Surface: `telegram/agent_config.py`, `telegram/bot.py`, +`telegram/http_chat_proxy.py`, `telegram/agents/leo.yaml`. + +Acceptance: + +- Deploy scripts sync `telegram/` into `/opt/teleo-eval/telegram/`, matching + `teleo-agent@.service`. +- Leo config validates in the production venv. +- `teleo-agent@leo.service` starts without restarting Rio or Theseus. +- A Telegram DM/tag reaches the HTTP proxy branch. +- Failure from the HTTP route returns a clear fail-closed Telegram response. + +Evidence: + +- `docs/reports/telegram-leo-x402-bridge-proof.json` +- `journalctl -u teleo-agent@leo -n 100 --no-pager` +- Telegram screenshot/export for the delivered reply. + +### TLG-002: Retain Live Telegram Proof + +Surface: `scripts/check_telegram_leo_x402_bridge.py` plus a live deployment +proof artifact after promotion. + +Acceptance: + +- Proof names the public Telegram bot handle and public Leo HTTP URL. +- Proof says whether the message was Telegram-delivered or HTTP-only. +- Proof includes no token values, secrets, chat-private content beyond the test + prompt and Leo reply. + +### X402-FARE-001: Make Faremeter The Default Seller Rail + +Surface: Living IP x402 route configuration and operator docs in the x402 +worktree. + +Acceptance: + +- Public sponsor-research route keeps using the self-hosted Faremeter path. +- Fresh public readback for `https://leo.livingip.xyz/api/initiatives/sponsor-research` + returns the intended mainnet 0.01 USDC challenge, not the stale Devnet + `payment_challenge_unavailable` response. +- A repeat public canary command is documented with the smallest safe spend cap. +- No PayAI/CDP dependency is required for the default seller rail. + +Existing evidence: + +- `ops/x402-faremeter-mainnet-public-payment-proof.json` +- `ops/x402-faremeter-hosted-candidate-payment-proof.json` +- `ops/x402-faremeter-direct-payment-proof.json` + +### LEO-OUT-001: Telegram Surface For Paid Research Results + +Surface: Telegram Leo bridge plus retained paid-source artifacts. + +Acceptance: + +- Telegram Leo can answer a question using the same public Leo HTTP behavior + that already consumed paid AgentCash research. +- The answer references retained paid-source evidence without claiming a fresh + payment unless a fresh payment receipt exists. + +Existing evidence: + +- `ops/x402-agentcash-paid-readback-proof.json` +- `ops/x402-leo-paid-research-analysis-proof.json` + +## Reviewer CTA + +Approve the PR-shaped Telegram bridge and then run the production promotion +commands from `docs/telegram-leo-x402-bridge-pr-packet.md`. Do not wait on +Corbits/Herd broadening to ship the Telegram transport and self-hosted Faremeter +seller rail. diff --git a/scripts/check_telegram_leo_x402_bridge.py b/scripts/check_telegram_leo_x402_bridge.py new file mode 100644 index 0000000..629631a --- /dev/null +++ b/scripts/check_telegram_leo_x402_bridge.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Prove the Telegram Leo bridge can consume the public Leo HTTP chat route.""" + +# ruff: noqa: E402, I001 + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +TELEGRAM_DIR = REPO_ROOT / "telegram" +sys.path.insert(0, str(TELEGRAM_DIR)) + +from http_chat_proxy import build_chat_proxy_payload, post_chat_proxy + + +DEFAULT_URL = "https://leo.livingip.xyz/api/agents/leo/chat" +DEFAULT_OUTPUT = "docs/reports/telegram-leo-x402-bridge-proof.json" + + +async def run_check(url: str, message: str) -> dict: + payload = build_chat_proxy_payload( + message=message, + source="telegram-proof", + agent="leo", + chat_id=0, + message_id=0, + username="codex-proof", + ) + status, body, reply = await post_chat_proxy(url=url, payload=payload) + return { + "schema": "livingip.telegramLeoX402BridgeProof.v1", + "generatedAt": datetime.now(timezone.utc).isoformat(), + "ok": bool(reply), + "requiredTier": "T3_live_readonly", + "currentTier": "T3_live_readonly" if reply else "T2_runtime", + "url": url, + "httpStatus": status, + "routeSchema": body.get("schema") if isinstance(body, dict) else None, + "agent": body.get("agent") if isinstance(body, dict) else None, + "llmOk": ( + body.get("llmOk") + if isinstance(body, dict) and "llmOk" in body + else body.get("llm", {}).get("ok") + if isinstance(body.get("llm"), dict) + else None + ), + "reply": reply, + "secretValuesIncluded": False, + "strongestClaimAllowed": ( + "Telegram bridge helper can POST a no-secret payload to the public Leo HTTP chat route " + "and extract a usable Leo reply. This proves the bridge parser/readback only; it does " + "not prove the Telegram bot service is deployed or active." + ), + "notProven": [ + "teleo-agent@leo.service active", + "Telegram message delivery", + "Telegram reply delivery", + "new payment execution", + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--url", default=DEFAULT_URL) + parser.add_argument( + "--message", + default="Telegram bridge proof: reply with one sentence confirming this reached Leo HTTP.", + ) + parser.add_argument("--output", default=DEFAULT_OUTPUT) + args = parser.parse_args() + + proof = asyncio.run(run_check(args.url, args.message)) + output_path = REPO_ROOT / args.output + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(proof, indent=2, sort_keys=True) + "\n") + print(json.dumps(proof, indent=2, sort_keys=True)) + return 0 if proof["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/telegram/agent_config.py b/telegram/agent_config.py index a28c4a9..51cf510 100644 --- a/telegram/agent_config.py +++ b/telegram/agent_config.py @@ -11,8 +11,8 @@ import logging import os import re from dataclasses import dataclass, field -from pathlib import Path from typing import Optional +from urllib.parse import urlparse logger = logging.getLogger("tg.agent_config") @@ -43,6 +43,9 @@ class AgentConfig: triage_model: str = "anthropic/claude-haiku-4.5" max_tokens: int = 1024 max_response_per_user_per_hour: int = 30 + http_chat_proxy_url: Optional[str] = None + respond_to_private_chats: bool = False + mention_aliases: list[str] = field(default_factory=list) def to_dict(self) -> dict: """Convert to dict for passing to build_system_prompt.""" @@ -55,6 +58,9 @@ class AgentConfig: "voice_summary": self.voice_summary, "domain_expertise": self.domain_expertise, "pentagon_agent_id": self.pentagon_agent_id, + "http_chat_proxy_url": self.http_chat_proxy_url, + "respond_to_private_chats": self.respond_to_private_chats, + "mention_aliases": self.mention_aliases, } @property @@ -100,6 +106,16 @@ def load_agent_config(config_path: str) -> AgentConfig: if "learnings_file" not in raw: errors.append("Missing required field: learnings_file") + proxy_url = raw.get("http_chat_proxy_url") + if proxy_url: + parsed_proxy = urlparse(proxy_url) + if parsed_proxy.scheme not in {"http", "https"} or not parsed_proxy.netloc: + errors.append("http_chat_proxy_url must be an absolute http(s) URL") + + mention_aliases = raw.get("mention_aliases", []) + if mention_aliases and not isinstance(mention_aliases, list): + errors.append("mention_aliases must be a list") + if errors: raise ValueError( f"Agent config validation failed ({config_path}):\n" @@ -123,6 +139,9 @@ def load_agent_config(config_path: str) -> AgentConfig: triage_model=raw.get("triage_model", "anthropic/claude-haiku-4.5"), max_tokens=raw.get("max_tokens", 1024), max_response_per_user_per_hour=raw.get("max_response_per_user_per_hour", 30), + http_chat_proxy_url=proxy_url, + respond_to_private_chats=bool(raw.get("respond_to_private_chats", False)), + mention_aliases=mention_aliases, ) diff --git a/telegram/agents/leo.yaml b/telegram/agents/leo.yaml new file mode 100644 index 0000000..860bdf7 --- /dev/null +++ b/telegram/agents/leo.yaml @@ -0,0 +1,57 @@ +# Leo — Living IP x402 research agent +# This config makes Telegram a thin transport to Leo's hosted HTTP chat route. + +# ─── Identity ──────────────────────────────────────────────────────────── +name: Leo +handle: "@TeleoHumanBot" +x_handle: "@teLEOhuman" +mention_aliases: + - "@leo" + - "@teLEOhuman" + - "@TeleoHumanBot" + - "@teLEOhumanity" +bot_token_file: leo-telegram-bot-token +pentagon_agent_id: livingip-leo +domain: collective-intelligence +domain_expertise: > + collective intelligence, coordination systems, Living IP strategy, agent + markets, paid research, x402 service rails, and cross-domain synthesis + +# ─── Hosted Leo Runtime ────────────────────────────────────────────────── +http_chat_proxy_url: "https://leo.livingip.xyz/api/agents/leo/chat" +respond_to_private_chats: true + +# ─── KB Scope ──────────────────────────────────────────────────────────── +kb_scope: + primary: + - domains/collective-intelligence + - domains/ai-alignment + - domains/space-development + - foundations + - core + +# ─── Voice ─────────────────────────────────────────────────────────────── +voice_summary: "Cross-domain strategist. Direct, synthesis-heavy, proof-aware." + +voice_definition: | + ## Register + You are Leo, TeleoHumanity's cross-domain strategy and collective + intelligence agent. Be direct and synthesis-heavy. Prefer concrete + mechanisms, coordination failures, and next actions over broad abstractions. + + ## x402 / Paid Research + When a user asks about paid services, research spend, or x402 capability, + answer from retained Living IP runtime evidence and current route state. + Do not claim payment execution unless the HTTP route returns retained + payment/readback evidence. + +# ─── Learnings ─────────────────────────────────────────────────────────── +learnings_file: agents/leo/learnings.md + +# ─── Model ─────────────────────────────────────────────────────────────── +response_model: anthropic/claude-opus-4-6 +triage_model: anthropic/claude-haiku-4.5 +max_tokens: 500 + +# ─── Rate Limits ───────────────────────────────────────────────────────── +max_response_per_user_per_hour: 30 diff --git a/telegram/bot.py b/telegram/bot.py index 2a0c6b1..b7e9c86 100644 --- a/telegram/bot.py +++ b/telegram/bot.py @@ -50,6 +50,7 @@ from retrieval import orchestrate_retrieval from market_data import get_token_price, format_price_context from worktree_lock import main_worktree_lock from x_client import search_tweets, fetch_from_url, check_research_rate_limit, record_research_usage, get_research_remaining +from http_chat_proxy import build_chat_proxy_payload, post_chat_proxy # ─── Config ───────────────────────────────────────────────────────────── @@ -75,6 +76,15 @@ TRIAGE_MODEL = "anthropic/claude-haiku-4.5" # Haiku for batch triage # KB scope — None means all domains (Rio default). Set from YAML config for other agents. AGENT_KB_SCOPE: list[str] | None = None +AGENT_NAME = "Rio" +AGENT_HANDLE = "@FutAIrdBot" +AGENT_X_HANDLE = "@futaRdIO" +AGENT_DOMAIN_EXPERTISE = ( + "futarchy, prediction markets, token governance, and the MetaDAO ecosystem" +) +AGENT_HTTP_CHAT_PROXY_URL: str | None = None +AGENT_RESPOND_TO_PRIVATE_CHATS = False +AGENT_MENTION_ALIASES = ["@teleo", "@FutAIrdBot"] # Rate limits MAX_RESPONSE_PER_USER_PER_HOUR = 30 @@ -984,7 +994,7 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): async def handle_tagged(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Handle messages that tag the bot — Rio responds with Opus.""" + """Handle messages that tag the bot.""" if not update.message or not update.message.text: return @@ -999,6 +1009,82 @@ async def handle_tagged(update: Update, context: ContextTypes.DEFAULT_TYPE): logger.info("Tagged by @%s: %s", user.username if user else "unknown", text[:100]) + if AGENT_HTTP_CHAT_PROXY_URL: + await msg.chat.send_action("typing") + payload = build_chat_proxy_payload( + message=text, + source="telegram", + agent=AGENT_NAME.lower(), + chat_id=msg.chat_id, + message_id=msg.message_id, + username=user.username if user else None, + ) + try: + status, proxy_body, proxy_reply = await post_chat_proxy( + url=AGENT_HTTP_CHAT_PROXY_URL, + payload=payload, + ) + except Exception as e: + logger.warning("%s HTTP chat proxy failed: %s", AGENT_NAME, e) + await msg.reply_text( + f"{AGENT_NAME}'s HTTP chat route is temporarily unavailable. " + "Try again after the service recovers.", + do_quote=True, + ) + return + + if not proxy_reply: + logger.warning("%s HTTP chat proxy returned no reply (status=%s)", AGENT_NAME, status) + await msg.reply_text( + f"{AGENT_NAME}'s HTTP chat route returned no usable reply. " + "The Telegram bridge is fail-closed.", + do_quote=True, + ) + return + + if len(proxy_reply) <= 4096: + await msg.reply_text(proxy_reply, do_quote=True) + else: + first = True + remaining = proxy_reply + while remaining: + chunk = remaining[:4096] + await msg.reply_text(chunk, quote=first) + first = False + remaining = remaining[4096:] + + if user: + username = user.username or "anonymous" + key = (msg.chat_id, user.id) + unanswered_count[key] = 0 + entry = {"user": text[:500], "bot": proxy_reply[:500], "username": username} + history = conversation_history.setdefault(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[user.id].append(time.time()) + + logger.info("%s proxied Telegram reply via HTTP chat route (status=%s)", AGENT_NAME, status) + _record_transcript( + msg, + proxy_reply, + is_bot=True, + rio_response=proxy_reply, + internal={ + "agent": AGENT_NAME.lower(), + "http_chat_proxy": True, + "http_status": status, + "schema": proxy_body.get("schema") if isinstance(proxy_body, dict) else None, + "llm_ok": proxy_body.get("llmOk") if isinstance(proxy_body, dict) else None, + }, + ) + return + # ─── Audit: init timing and tool call tracking ────────────────── response_start = time.monotonic() tool_calls = [] @@ -1913,9 +1999,9 @@ tags: [telegram, ownership-community] async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle /start command.""" await update.message.reply_text( - "I'm Rio, the internet finance agent for TeleoHumanity's collective knowledge base. " - "Tag me with @teleo to ask about futarchy, prediction markets, token governance, " - "or anything in our domain. I'll ground my response in our KB's evidence." + f"I'm {AGENT_NAME}, a TeleoHumanity agent. " + f"Tag me with {AGENT_HANDLE} to ask about {AGENT_DOMAIN_EXPERTISE}. " + "I'll ground my response in the configured agent runtime." ) @@ -1937,10 +2023,21 @@ def _load_agent_config(config_path: str): """Load agent YAML config and set module-level variables.""" global BOT_TOKEN_FILE, RESPONSE_MODEL, TRIAGE_MODEL, AGENT_KB_SCOPE global LEARNINGS_FILE, MAX_RESPONSE_PER_USER_PER_HOUR + global AGENT_NAME, AGENT_HANDLE, AGENT_X_HANDLE, AGENT_DOMAIN_EXPERTISE + global AGENT_HTTP_CHAT_PROXY_URL, AGENT_RESPOND_TO_PRIVATE_CHATS, AGENT_MENTION_ALIASES with open(config_path) as f: cfg = yaml.safe_load(f) + AGENT_NAME = cfg.get("name", AGENT_NAME) + AGENT_HANDLE = cfg.get("handle", AGENT_HANDLE) + AGENT_X_HANDLE = cfg.get("x_handle", AGENT_X_HANDLE) + AGENT_DOMAIN_EXPERTISE = cfg.get("domain_expertise", AGENT_DOMAIN_EXPERTISE) + AGENT_HTTP_CHAT_PROXY_URL = cfg.get("http_chat_proxy_url") + AGENT_RESPOND_TO_PRIVATE_CHATS = bool(cfg.get("respond_to_private_chats", False)) + aliases = [AGENT_HANDLE, *cfg.get("mention_aliases", [])] + AGENT_MENTION_ALIASES = sorted({alias for alias in aliases if alias}) + if cfg.get("bot_token_file"): BOT_TOKEN_FILE = f"/opt/teleo-eval/secrets/{cfg['bot_token_file']}" if cfg.get("response_model"): @@ -1959,6 +2056,17 @@ def _load_agent_config(config_path: str): return cfg +def _mention_filter_regex(agent_cfg: dict | None) -> str: + """Build the Telegram mention regex for the active agent.""" + aliases = ["@teleo", "@FutAIrdBot"] + if agent_cfg: + aliases = [agent_cfg.get("handle", ""), *agent_cfg.get("mention_aliases", [])] + cleaned = sorted({alias for alias in aliases if alias}) + if not cleaned: + cleaned = ["@teleo", "@FutAIrdBot"] + return "(?i)(" + "|".join(re.escape(alias) for alias in cleaned) + ")" + + def main(): """Start the bot.""" parser = argparse.ArgumentParser() @@ -2014,10 +2122,16 @@ def main(): # python-telegram-bot filters.Mention doesn't work for bot mentions in groups # Use a regex filter for the bot username app.add_handler(MessageHandler( - filters.TEXT & filters.Regex(r"(?i)(@teleo|@futairdbot)"), + filters.TEXT & filters.Regex(_mention_filter_regex(agent_cfg)), handle_tagged, )) + if agent_cfg and AGENT_RESPOND_TO_PRIVATE_CHATS: + app.add_handler(MessageHandler( + filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, + handle_tagged, + )) + # Reply handler — replies to the bot's own messages continue the conversation reply_to_bot_filter = filters.TEXT & filters.REPLY & ~filters.COMMAND app.add_handler(MessageHandler( diff --git a/telegram/http_chat_proxy.py b/telegram/http_chat_proxy.py new file mode 100644 index 0000000..4bb17e2 --- /dev/null +++ b/telegram/http_chat_proxy.py @@ -0,0 +1,80 @@ +"""HTTP chat proxy helpers for opt-in Telegram agent routing.""" + +from __future__ import annotations + +from typing import Any + + +def build_chat_proxy_payload( + *, + message: str, + source: str, + agent: str, + chat_id: int | None = None, + message_id: int | None = None, + username: str | None = None, +) -> dict[str, Any]: + """Build the no-secret payload sent from Telegram to an agent HTTP chat route.""" + metadata = { + "source": source, + "agent": agent, + "chat_id": chat_id, + "message_id": message_id, + "username": username, + } + return { + "message": message, + "metadata": {k: v for k, v in metadata.items() if v is not None}, + } + + +def extract_chat_proxy_reply(payload: dict[str, Any]) -> str | None: + """Extract a reply from supported Living IP Leo chat response shapes.""" + if not isinstance(payload, dict): + return None + + direct_reply = payload.get("reply") + if isinstance(direct_reply, str) and direct_reply.strip(): + return direct_reply.strip() + + decision = payload.get("decision") + if isinstance(decision, dict): + decision_reply = decision.get("reply") + if isinstance(decision_reply, str) and decision_reply.strip(): + return decision_reply.strip() + + llm = payload.get("llm") + if isinstance(llm, dict): + llm_reply = llm.get("reply") + if isinstance(llm_reply, str) and llm_reply.strip(): + return llm_reply.strip() + llm_decision = llm.get("decision") + if isinstance(llm_decision, dict): + llm_decision_reply = llm_decision.get("reply") + if isinstance(llm_decision_reply, str) and llm_decision_reply.strip(): + return llm_decision_reply.strip() + + return None + + +async def post_chat_proxy( + *, + url: str, + payload: dict[str, Any], + timeout_seconds: int = 30, +) -> tuple[int, dict[str, Any], str | None]: + """POST to an agent HTTP chat route and return status, JSON body, and reply.""" + import aiohttp + + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout_seconds)) as session: + async with session.post( + url, + json=payload, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-LivingIP-Source": "telegram-agent-proxy", + }, + ) as resp: + data = await resp.json(content_type=None) + return resp.status, data, extract_chat_proxy_reply(data) diff --git a/tests/test_telegram_leo_x402_bridge.py b/tests/test_telegram_leo_x402_bridge.py new file mode 100644 index 0000000..1e5baa4 --- /dev/null +++ b/tests/test_telegram_leo_x402_bridge.py @@ -0,0 +1,87 @@ +"""Regression coverage for the Leo Telegram -> Living IP HTTP chat bridge.""" + +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +TELEGRAM_DIR = REPO_ROOT / "telegram" +sys.path.insert(0, str(TELEGRAM_DIR)) + +from agent_config import load_agent_config # noqa: E402 +from http_chat_proxy import build_chat_proxy_payload, extract_chat_proxy_reply # noqa: E402 + + +def test_leo_config_opts_into_http_chat_proxy_without_changing_default_agents(): + leo = load_agent_config(str(TELEGRAM_DIR / "agents" / "leo.yaml")) + rio = load_agent_config(str(TELEGRAM_DIR / "agents" / "rio.yaml")) + + assert leo.name == "Leo" + assert leo.http_chat_proxy_url == "https://leo.livingip.xyz/api/agents/leo/chat" + assert leo.respond_to_private_chats is True + assert "@teLEOhuman" in leo.mention_aliases + assert rio.http_chat_proxy_url is None + assert rio.respond_to_private_chats is False + + +def test_invalid_http_chat_proxy_url_fails_closed(tmp_path): + config = tmp_path / "bad.yaml" + config.write_text( + """ +name: Leo +handle: "@teLEOhuman" +bot_token_file: leo-telegram-bot-token +pentagon_agent_id: livingip-leo +domain: collective-intelligence +kb_scope: + primary: ["core"] +voice_summary: "test" +voice_definition: "test" +learnings_file: agents/leo/learnings.md +http_chat_proxy_url: "not-a-url" +""".strip() + ) + + with pytest.raises(ValueError, match="http_chat_proxy_url"): + load_agent_config(str(config)) + + +def test_proxy_payload_contains_no_secret_material(): + payload = build_chat_proxy_payload( + message="Can Leo use x402 paid research now?", + source="telegram", + agent="leo", + chat_id=123, + message_id=456, + username="tester", + ) + + assert payload == { + "message": "Can Leo use x402 paid research now?", + "metadata": { + "source": "telegram", + "agent": "leo", + "chat_id": 123, + "message_id": 456, + "username": "tester", + }, + } + assert "token" not in str(payload).lower() + assert "secret" not in str(payload).lower() + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ({"reply": "public route reply"}, "public route reply"), + ({"decision": {"reply": "analysis route reply"}}, "analysis route reply"), + ({"llm": {"decision": {"reply": "nested decision reply"}}}, "nested decision reply"), + ], +) +def test_extract_chat_proxy_reply_accepts_retained_leo_shapes(payload, expected): + assert extract_chat_proxy_reply(payload) == expected + + +def test_extract_chat_proxy_reply_fails_closed_on_missing_reply(): + assert extract_chat_proxy_reply({"schema": "livingip.x402.leoChatResponse.v1"}) is None