Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Telegram inline command picker — searchable access to EVERY command/skill.
Telegram's BotCommand menu is capped (100 per scope, ~4KB payload; Hermes
defaults to 60 slots), so most skill commands can never appear in the ``/``
menu. Inline mode has no such cap: typing ``@yourbot <query>`` in any chat
asks the bot for results live, per keystroke, paginated 50 at a time — the
same trick Discord's ``/skill`` autocomplete uses (options fetched
dynamically, nothing pre-registered).
Tapping a result sends the command text (e.g. ``/plan migrate the auth``)
into the chat as the user. Because the sent message starts with ``/``, the
bot receives it even under Telegram's default privacy mode ("messages with
commands meant for the bot" are always delivered), and it dispatches through
the existing command path — zero new dispatch code.
This module is PTB-object-free on purpose: it returns plain dicts so the
catalog/filter/pagination logic is unit-testable without python-telegram-bot
installed. The adapter converts dicts to ``InlineQueryResultArticle``.
Setup note (docs): inline mode must be enabled once per bot via BotFather's
``/setinline``. Until then Telegram never delivers ``inline_query`` updates,
so the registered handler is inert — safe to ship enabled by default.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Tuple
logger = logging.getLogger(__name__)
# Telegram hard limit: max 50 results per answerInlineQuery call.
PAGE_SIZE = 50
# Results depend on the caller's auth and the install's skill set — never
# share cached results across users, and keep the cache short so freshly
# installed skills appear quickly.
CACHE_TIME_SECONDS = 10
def collect_inline_catalog() -> List[Dict[str, str]]:
"""Return every dispatchable command as ``{name, description}`` dicts.
Sources, deduped in priority order (first occurrence wins):
1. Core gateway-visible ``CommandDef`` commands (Telegram-sanitized
names, same gating as the BotCommand menu).
2. Plugin slash commands + built-in skill commands via the shared
collector — with ``max_slots=None`` so NOTHING is trimmed. This is
the whole point: the inline picker has no cap.
Skill entries honor the same filtering as the menu (hub excluded,
per-platform disabled excluded, external-dir allowlist).
"""
catalog: List[Dict[str, str]] = []
seen: set[str] = set()
try:
from hermes_cli.commands import (
_collect_gateway_skill_entries,
_sanitize_telegram_name,
telegram_bot_commands,
)
except Exception: # pragma: no cover - defensive
logger.debug("inline picker: commands registry unavailable", exc_info=True)
return catalog
try:
for name, desc in telegram_bot_commands():
if name and name not in seen:
seen.add(name)
catalog.append({"name": name, "description": desc or ""})
except Exception:
logger.debug("inline picker: core command collection failed", exc_info=True)
try:
entries, _hidden = _collect_gateway_skill_entries(
platform="telegram",
max_slots=None, # inline mode has no cap — collect everything
reserved_names=set(seen),
desc_limit=100,
sanitize_name=_sanitize_telegram_name,
)
for entry in entries:
# Entry shape is (name, desc, cmd_key[, raw_name]) — tolerate both.
name, desc = entry[0], entry[1]
if name and name not in seen:
seen.add(name)
catalog.append({"name": name, "description": desc or ""})
except Exception:
logger.debug("inline picker: skill/plugin collection failed", exc_info=True)
return catalog
def filter_catalog(catalog: List[Dict[str, str]], term: str) -> List[Dict[str, str]]:
"""Rank *catalog* against *term*: prefix > name-substring > description.
Empty term returns the full catalog in its collection order (core first,
then plugins, then skills alphabetically) — the "browse" view.
"""
term = (term or "").strip().lower().lstrip("/")
if not term:
return list(catalog)
prefix: List[Dict[str, str]] = []
name_sub: List[Dict[str, str]] = []
desc_sub: List[Dict[str, str]] = []
# Treat hyphens/underscores as equivalent, mirroring command dispatch.
norm_term = term.replace("-", "_")
for item in catalog:
norm_name = item["name"].lower().replace("-", "_")
if norm_name.startswith(norm_term):
prefix.append(item)
elif norm_term in norm_name:
name_sub.append(item)
elif term in (item.get("description") or "").lower():
desc_sub.append(item)
return prefix + name_sub + desc_sub
def build_inline_results(
query: str,
offset: str = "",
page_size: int = PAGE_SIZE,
) -> Tuple[List[Dict[str, Any]], str]:
"""Build one page of inline results for *query*.
The first whitespace-separated token of *query* filters the catalog; any
remainder is carried into the sent command as its argument. Example:
``@bot plan migrate auth to OIDC`` → filter ``plan``, and tapping the
``/plan`` result sends ``/plan migrate auth to OIDC``.
Returns ``(results, next_offset)`` where each result is
``{"id", "title", "description", "message_text"}`` and *next_offset* is
``""`` when this is the last page (Telegram's stop signal).
"""
query = (query or "").strip()
parts = query.split(None, 1)
term = parts[0] if parts else ""
args = parts[1].strip() if len(parts) > 1 else ""
matches = filter_catalog(collect_inline_catalog(), term)
try:
start = int(offset) if offset else 0
except (TypeError, ValueError):
start = 0
page = matches[start:start + page_size]
next_offset = str(start + page_size) if len(matches) > start + page_size else ""
results: List[Dict[str, Any]] = []
for item in page:
message_text = f"/{item['name']}"
if args:
message_text += f" {args}"
results.append(
{
# Offset-scoped ids stay unique across pages of one query.
"id": f"{start}:{item['name']}"[:64],
"title": f"/{item['name']}",
"description": (item.get("description") or "")[:100],
"message_text": message_text[:4096],
}
)
return results, next_offset
+35
View File
@@ -0,0 +1,35 @@
name: telegram-platform
label: Telegram
kind: platform
version: 1.0.0
description: >
Telegram gateway adapter for Hermes Agent.
Connects to Telegram via python-telegram-bot and relays messages between
Telegram chats/groups/topics and the Hermes agent. Supports threads/topics,
streaming edits, native media, inline keyboards, slash commands, fallback
network transport (direct-IP failover), notification modes, mention gating,
and per-user/chat allowlists.
author: NousResearch
requires_env:
- name: TELEGRAM_BOT_TOKEN
description: "Telegram bot token from @BotFather"
prompt: "Telegram bot token"
url: "https://t.me/BotFather"
password: true
optional_env:
- name: TELEGRAM_ALLOWED_USERS
description: "Comma-separated Telegram user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: TELEGRAM_ALLOW_ALL_USERS
description: "Allow any Telegram user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: TELEGRAM_HOME_CHANNEL
description: "Default chat ID for cron / notification delivery"
prompt: "Home channel ID"
password: false
- name: TELEGRAM_HOME_CHANNEL_NAME
description: "Display name for the Telegram home channel"
prompt: "Home channel display name"
password: false
@@ -0,0 +1,51 @@
"""Helpers for Telegram Bot API chat identifiers.
Telegram's Bot API accepts a ``chat_id`` in two forms: a numeric ID (an int,
e.g. ``123456789`` for a DM or ``-1001234567890`` for a channel/supergroup) or
an ``@username`` string for public channels and groups. Hermes historically
coerced every ``chat_id`` with ``int()``, which crashes on the username form
(``ValueError: invalid literal for int()``). Normalizing here lets numeric IDs
pass through as ints while usernames pass through unchanged — both are valid
values for the Bot API.
"""
from __future__ import annotations
import re
from typing import Any, Union
# Telegram usernames are 5-32 chars: letters, digits, underscores, with a
# leading "@". (Telegram also permits 4-char usernames for some legacy/official
# accounts, but the 5-32 public rule is the safe lower bound for routing.)
_TELEGRAM_USERNAME_RE = re.compile(r"@[A-Za-z0-9_]{4,32}")
def normalize_telegram_chat_id(chat_id: Any) -> Union[int, str]:
"""Return a Bot API-compatible chat_id.
Numeric values (incl. negative channel IDs) are returned as ``int``; any
non-numeric value (e.g. an ``@username``) is returned as a stripped string.
Telegram's Bot API accepts both, so this never raises on a username the way
a bare ``int(chat_id)`` would.
"""
chat_id_str = str(chat_id).strip()
try:
return int(chat_id_str)
except (TypeError, ValueError):
return chat_id_str
def telegram_chat_id_key(chat_id: Any) -> str:
"""Stable string key for a chat_id (for dict keys / persisted state)."""
return str(normalize_telegram_chat_id(chat_id))
def looks_like_telegram_username(chat_id: Any) -> bool:
"""True when the value is an ``@username``-format Telegram chat identifier."""
return bool(_TELEGRAM_USERNAME_RE.fullmatch(str(chat_id).strip()))
def parse_telegram_username_target(target_ref: Any) -> Union[str, None]:
"""Return the value when it is an ``@username`` target, else ``None``."""
value = str(target_ref).strip()
return value if looks_like_telegram_username(value) else None
@@ -0,0 +1,379 @@
"""Telegram-specific network helpers.
Provides a hostname-preserving fallback transport for networks where
api.telegram.org resolves to an endpoint that is unreachable from the current
host. The transport keeps the logical request host and TLS SNI as
api.telegram.org while retrying the TCP connection against one or more fallback
IPv4 addresses.
"""
from __future__ import annotations
import asyncio
import ipaddress
import logging
import socket
from typing import Iterable, Optional
import httpx
logger = logging.getLogger(__name__)
_TELEGRAM_API_HOST = "api.telegram.org"
# TCP keepalive so a half-open or CLOSE-WAIT long-poll errors out instead of
# blocking getUpdates indefinitely. Windows does not enable SO_KEEPALIVE on
# new sockets by default, so a dead api.telegram.org peer can hang forever
# (#87057). Idle/interval knobs are best-effort — not every Python/OS combo
# exposes TCP_KEEPIDLE / TCP_KEEPALIVE.
_TCP_KEEPALIVE_IDLE_S = 30
_TCP_KEEPALIVE_INTERVAL_S = 10
_TCP_KEEPALIVE_COUNT = 3
def tcp_keepalive_socket_options() -> list[tuple[int, int, int]]:
"""Return ``setsockopt`` tuples that enable TCP keepalive on new sockets.
Pure data for httpx/httpcore ``socket_options``. Safe on every host: the
list always includes ``SO_KEEPALIVE`` and adds idle/interval/count only
when the running interpreter exposes those option names.
"""
options: list[tuple[int, int, int]] = [
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
]
idle = getattr(socket, "TCP_KEEPIDLE", None) or getattr(socket, "TCP_KEEPALIVE", None)
if idle is not None:
options.append((socket.IPPROTO_TCP, idle, _TCP_KEEPALIVE_IDLE_S))
interval = getattr(socket, "TCP_KEEPINTVL", None)
if interval is not None:
options.append((socket.IPPROTO_TCP, interval, _TCP_KEEPALIVE_INTERVAL_S))
count = getattr(socket, "TCP_KEEPCNT", None)
if count is not None:
options.append((socket.IPPROTO_TCP, count, _TCP_KEEPALIVE_COUNT))
return options
# DNS-over-HTTPS providers used to discover Telegram API IPs that may differ
# from the (potentially unreachable) IP returned by the local system resolver.
_DOH_TIMEOUT = 4.0 # seconds — bounded so connect() isn't noticeably delayed
_DOH_PROVIDERS: list[dict] = [
{
"url": "https://dns.google/resolve",
"params": {"name": _TELEGRAM_API_HOST, "type": "A"},
"headers": {},
},
{
"url": "https://cloudflare-dns.com/dns-query",
"params": {"name": _TELEGRAM_API_HOST, "type": "A"},
"headers": {"Accept": "application/dns-json"},
},
]
# Last-resort IPv4 Telegram Bot API endpoints in 149.154.160.0/20
# (same seed used by OpenClaw). Used when DoH is blocked AND as the
# first-try connect targets so a blackholed IPv6 AAAA for the hostname
# cannot pin initialize() (#87015).
SEED_FALLBACK_IPS: list[str] = ["149.154.166.110", "149.154.167.220"]
_UNSET = object()
def _resolve_proxy_url(target_hosts=None) -> str | None:
# Delegate to shared implementation (env vars + macOS system proxy detection)
from gateway.platforms.base import resolve_proxy_url
return resolve_proxy_url("TELEGRAM_PROXY", target_hosts=target_hosts)
class TelegramFallbackTransport(httpx.AsyncBaseTransport):
"""Reach Telegram Bot API via known IPv4 literals first, hostname last.
Requests still target https://api.telegram.org/... logically (Host + SNI
stay on the hostname). TCP connects to a known A-record IP first so a
blackholed IPv6 AAAA cannot pin initialize(). Equivalent to
``curl --resolve api.telegram.org:443:<ip>``. The dual-stack hostname
is last resort for IPv6-only networks.
"""
# Bound every pool. httpx defaults to 100 connections per pool, so a wedged
# endpoint plus the seed IPs can outgrow the process file-descriptor limit
# on its own (#63311).
_POOL_LIMITS = httpx.Limits(max_connections=8, max_keepalive_connections=4)
def __init__(self, fallback_ips: Iterable[str], **transport_kwargs):
self._fallback_ips = list(dict.fromkeys(_normalize_fallback_ips(fallback_ips)))
proxy_url = _resolve_proxy_url(target_hosts=[_TELEGRAM_API_HOST, *self._fallback_ips])
if proxy_url and "proxy" not in transport_kwargs:
transport_kwargs["proxy"] = proxy_url
transport_kwargs.setdefault("limits", self._POOL_LIMITS)
transport_kwargs.setdefault("socket_options", tcp_keepalive_socket_options())
self._transport_kwargs = transport_kwargs
self._primary = httpx.AsyncHTTPTransport(**transport_kwargs)
self._primary_lock = asyncio.Lock()
self._primary_closed = False
# Built on demand and discarded on failure — see _reset_fallback.
self._fallbacks: dict[str, httpx.AsyncHTTPTransport] = {}
self._fallback_lock = asyncio.Lock()
# ``_UNSET`` vs ``None`` vs ``str``: unset / sticky hostname / sticky IPv4.
# ``None`` cannot mean both "no sticky yet" and "sticky dual-stack
# hostname" (#87015).
self._sticky_ip: object = _UNSET
self._sticky_lock = asyncio.Lock()
async def _get_fallback(self, ip: str) -> httpx.AsyncHTTPTransport:
async with self._fallback_lock:
transport = self._fallbacks.get(ip)
if transport is None:
transport = httpx.AsyncHTTPTransport(**self._transport_kwargs)
self._fallbacks[ip] = transport
return transport
async def _reset_primary(self, transport: httpx.AsyncHTTPTransport) -> None:
# Retryable primary failures can leave half-closed sockets in the pool;
# replace and close the failed generation before trying fallback.
async with self._primary_lock:
if self._primary_closed or transport is not self._primary:
return
self._primary = httpx.AsyncHTTPTransport(**self._transport_kwargs)
try:
await transport.aclose()
except Exception as exc:
logger.debug("[Telegram] Error closing primary transport: %s", exc)
async def _reset_fallback(self, ip: str) -> None:
"""Discard a failed fallback pool so its dead sockets are released.
A connect that reaches ESTABLISHED and is then closed by the peer leaves
its socket in CLOSE_WAIT inside the pool. Retaining the poisoned pool
leaks one descriptor per retry until the process hits its file limit and
can no longer accept connections or resolve DNS (#63311).
"""
async with self._fallback_lock:
transport = self._fallbacks.pop(ip, None)
if transport is None:
return
try:
await transport.aclose()
except Exception as exc: # closing a broken pool must never mask the real error
logger.debug("[Telegram] Error closing fallback transport %s: %s", ip, exc)
def _attempt_order(self) -> list[Optional[str]]:
"""IPv4 literals first; dual-stack hostname last.
A blackholed IPv6 path to ``api.telegram.org`` never errors — Happy
Eyeballs waits on AAAA until the OS TCP timeout, which can pin the
event loop so ``_await_with_thread_deadline`` never fires (#87015).
Known A-record IPs connect over IPv4 immediately. The hostname is
kept as a last resort for IPv6-only networks.
"""
order: list[Optional[str]] = []
if self._sticky_ip is not _UNSET:
sticky = self._sticky_ip
order.append(sticky if sticky is None else str(sticky))
for ip in self._fallback_ips:
if ip not in order:
order.append(ip)
if None not in order:
order.append(None)
return order
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
if request.url.host != _TELEGRAM_API_HOST or not self._fallback_ips:
return await self._primary.handle_async_request(request)
attempt_order = self._attempt_order()
last_error: Exception | None = None
for ip in attempt_order:
candidate = request if ip is None else _rewrite_request_for_ip(request, ip)
transport = self._primary if ip is None else await self._get_fallback(ip)
try:
response = await transport.handle_async_request(candidate)
if self._sticky_ip is _UNSET or self._sticky_ip != ip:
async with self._sticky_lock:
if self._sticky_ip is _UNSET or self._sticky_ip != ip:
self._sticky_ip = ip
if ip is not None:
log = logger.warning if last_error is not None else logger.info
log(
"[Telegram] Using sticky IPv4 Telegram API path %s "
"(dual-stack hostname tried last — #87015)",
ip,
)
return response
except Exception as exc:
last_error = exc
if not _is_retryable_connect_error(exc):
raise
if self._sticky_ip is not _UNSET and ip == self._sticky_ip:
async with self._sticky_lock:
if self._sticky_ip is not _UNSET and self._sticky_ip == ip:
self._sticky_ip = _UNSET
logger.warning(
"[Telegram] Sticky Telegram path %s failed; "
"re-walking IPv4 literals before the hostname",
ip if ip is not None else "api.telegram.org",
)
if ip is None:
await self._reset_primary(transport)
logger.warning(
"[Telegram] Dual-stack api.telegram.org path failed (%s)",
exc,
)
continue
logger.warning("[Telegram] IPv4 Telegram API IP %s failed: %s", ip, exc)
await self._reset_fallback(ip)
continue
if last_error is None:
raise RuntimeError("All Telegram fallback IPs exhausted but no error was recorded")
raise last_error
async def aclose(self) -> None:
async with self._primary_lock:
self._primary_closed = True
primary = self._primary
await primary.aclose()
async with self._fallback_lock:
transports = list(self._fallbacks.values())
self._fallbacks.clear()
for transport in transports:
await transport.aclose()
def _normalize_fallback_ips(values: Iterable[str]) -> list[str]:
normalized: list[str] = []
for value in values:
raw = str(value).strip()
if not raw:
continue
try:
addr = ipaddress.ip_address(raw)
except ValueError:
logger.warning("Ignoring invalid Telegram fallback IP: %r", raw)
continue
if addr.version != 4:
logger.warning("Ignoring non-IPv4 Telegram fallback IP: %s", raw)
continue
if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_unspecified:
logger.warning("Ignoring private/internal Telegram fallback IP: %s", raw)
continue
normalized.append(str(addr))
return normalized
def parse_fallback_ip_env(value: str | None) -> list[str]:
if not value:
return []
parts = [part.strip() for part in value.split(",")]
return _normalize_fallback_ips(parts)
def _resolve_system_dns() -> set[str]:
"""Return the IPv4 addresses that the OS resolver gives for api.telegram.org."""
try:
results = socket.getaddrinfo(_TELEGRAM_API_HOST, 443, socket.AF_INET)
return {addr[4][0] for addr in results}
except Exception:
return set()
async def _query_doh_provider(
client: httpx.AsyncClient, provider: dict
) -> list[str]:
"""Query one DoH provider and return A-record IPs."""
try:
resp = await client.get(
provider["url"], params=provider["params"], headers=provider["headers"]
)
resp.raise_for_status()
data = resp.json()
ips: list[str] = []
for answer in data.get("Answer", []):
if answer.get("type") != 1: # A record
continue
raw = answer.get("data", "").strip()
try:
ipaddress.ip_address(raw)
ips.append(raw)
except ValueError:
continue
return ips
except Exception as exc:
logger.debug("DoH query to %s failed: %s", provider["url"], exc)
return []
async def discover_fallback_ips() -> list[str]:
"""Auto-discover Telegram API IPs via DNS-over-HTTPS.
Resolves api.telegram.org through Google and Cloudflare DoH and returns all
unique A records. IPs that match the local system resolver are kept rather
than excluded: in many networks the system-DNS IP is the most reliable path
to api.telegram.org and a transient primary-path failure should be retried
against the same address via the IP-rewrite path before the seed list is
consulted (#14520). Falls back to a hardcoded seed list only when DoH
yields no usable answers.
"""
async with httpx.AsyncClient(timeout=httpx.Timeout(_DOH_TIMEOUT)) as client:
doh_tasks = [_query_doh_provider(client, p) for p in _DOH_PROVIDERS]
system_dns_task = asyncio.ensure_future(asyncio.to_thread(_resolve_system_dns))
results = await asyncio.gather(*doh_tasks, return_exceptions=True)
# The system-resolver leg runs socket.getaddrinfo in a worker thread with
# no timeout of its own — a wedged OS resolver (broken VPN/DNS) can sit for
# minutes. Its result only feeds the no-usable-answers log line below, so
# it must never gate discovery: bound it and move on (#63309). The DoH legs
# are already bounded by the client timeout above.
system_ips: set[str] = set()
try:
system_result = await asyncio.wait_for(system_dns_task, timeout=_DOH_TIMEOUT)
if isinstance(system_result, set):
system_ips = system_result
except Exception:
logger.debug("System-DNS resolution for %s did not complete in time", _TELEGRAM_API_HOST)
doh_ips: list[str] = []
for r in results:
if isinstance(r, list):
doh_ips.extend(r)
# Deduplicate preserving order
seen: set[str] = set()
candidates: list[str] = []
for ip in doh_ips:
if ip not in seen:
seen.add(ip)
candidates.append(ip)
# Validate through existing normalization
validated = _normalize_fallback_ips(candidates)
if validated:
logger.debug("Discovered Telegram fallback IPs via DoH: %s", ", ".join(validated))
return validated
logger.info(
"DoH discovery yielded no usable IPs (system DNS: %s); using seed fallback IPs %s",
", ".join(system_ips) or "unknown",
", ".join(SEED_FALLBACK_IPS),
)
return list(SEED_FALLBACK_IPS)
def _rewrite_request_for_ip(request: httpx.Request, ip: str) -> httpx.Request:
original_host = request.url.host or _TELEGRAM_API_HOST
url = request.url.copy_with(host=ip)
headers = request.headers.copy()
headers["host"] = original_host
extensions = dict(request.extensions)
extensions["sni_hostname"] = original_host
return httpx.Request(
method=request.method,
url=url,
headers=headers,
stream=request.stream,
extensions=extensions,
)
def _is_retryable_connect_error(exc: Exception) -> bool:
return isinstance(exc, (httpx.ConnectTimeout, httpx.ConnectError))