Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user