fix(telegram): expose proxy setup in WebUI (#5033)

This commit is contained in:
chengyongru
2026-07-23 14:18:49 +08:00
committed by GitHub
parent 3647875aba
commit 01cdfc8100
17 changed files with 472 additions and 24 deletions
+33 -1
View File
@@ -109,7 +109,24 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
<details>
<summary><b>Telegram</b></summary>
**Install the optional channel dependency**
**Recommended WebUI setup**
1. Create a bot with `@BotFather` and copy its token.
2. Run `nanobot webui`, then open **Settings → Channels → Telegram**.
3. Paste the token. If the gateway cannot reach Telegram directly, expand
**Advanced** and add an HTTP or SOCKS proxy.
4. Save and enable Telegram, then send the bot a direct message.
The configuration badge means nanobot found a saved token. The live connection
check is separate, so a temporary Telegram or proxy outage does not make an
existing configuration disappear. Saved tokens and proxy URLs remain masked.
See the [step-by-step Telegram guide](./guides/telegram-ai-agent.md) for pairing
and troubleshooting.
**Manual setup**
Install the optional channel dependency:
```bash
nanobot plugins enable telegram
@@ -134,6 +151,21 @@ nanobot plugins enable telegram
}
```
If the gateway cannot reach Telegram directly, add a proxy to the same section:
```json
{
"channels": {
"telegram": {
"proxy": "http://127.0.0.1:7890"
}
}
}
```
HTTP, HTTPS, SOCKS5, and SOCKS5H proxy URLs are accepted. Treat a proxy URL
containing a username or password as a secret.
> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. Copy this value **without the `@` symbol** and paste it into the config file.
>
> `richMessages` defaults to `false`. Set it to `true` only if your Telegram client supports Bot API 10.1 rich messages and you want richer markdown rendering; keep it disabled for Telegram Web, which may show unsupported-message errors for rich messages.
+42 -10
View File
@@ -1,8 +1,7 @@
# Build a Telegram AI Agent with nanobot
# Connect Telegram to nanobot
This guide connects nanobot to Telegram so a paired Telegram user can message a
self-hosted AI agent backed by your normal nanobot config, tools, memory, and
workspace.
This guide connects one Telegram bot to nanobot. Messages sent to that bot use
your normal nanobot model, tools, memory, and workspace.
## What this guide builds
@@ -29,27 +28,55 @@ python -m pip install nanobot-ai
nanobot onboard --wizard
```
## Enable the Telegram channel
## Connect Telegram in the WebUI
Install the optional channel dependency:
Start the WebUI:
```bash
nanobot webui
```
Open **Settings → Channels → Telegram**:
1. If Telegram support is not installed, turn on its switch and confirm the
installation.
2. Paste the token from BotFather.
3. If the gateway cannot reach Telegram directly, expand **Advanced** and enter
an HTTP or SOCKS proxy such as `http://127.0.0.1:7890`.
4. Save and enable Telegram.
The configuration badge appears as soon as a bot token is saved. A connection
check is separate: if Telegram is temporarily unreachable, the saved
configuration remains valid and the bot can continue working in environments
where the gateway has network access.
Saved tokens and proxy URLs are masked. A proxy entered here is used both for
the connection check and for normal Telegram traffic.
## Manual setup
For a headless installation, install Telegram support:
```bash
nanobot plugins enable telegram
```
Merge this snippet into `~/.nanobot/config.json`:
Then merge this snippet into `~/.nanobot/config.json`:
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN"
"token": "YOUR_BOT_TOKEN",
"proxy": "http://127.0.0.1:7890"
}
}
}
```
Omit `proxy` when the gateway can reach Telegram directly.
Omitting `allowFrom` enables pairing-only mode. The first DM from a new user
gets a pairing code instead of agent access.
@@ -95,8 +122,13 @@ workspace as your local CLI check.
- If the channel is not listed, run `nanobot plugins enable telegram` again in
the same Python environment.
- If messages do not arrive, run `nanobot gateway --verbose` and check the bot
token.
- If the WebUI shows a saved configuration but the live check cannot reach Telegram,
the token is still saved. Confirm the gateway can reach `api.telegram.org`,
or open **Advanced → Network proxy** and enter a proxy.
- If Telegram rejects the token, copy the current token from BotFather or
regenerate it.
- If messages do not arrive, run `nanobot gateway --verbose` and confirm the
Telegram channel is enabled.
- If a first DM returns a pairing code, that is expected. Approve the code before
testing normal agent replies.
- If Telegram Web shows unsupported rich messages, keep `richMessages` disabled.
+3 -1
View File
@@ -275,7 +275,9 @@ Then check:
|---|---|
| Bot never replies | Gateway is not running, the channel is not enabled, or the bot/app token is wrong. |
| Unknown sender ignored | Configure `allowFrom`, pairing, or the channel-specific allow list. |
| Telegram fails | Confirm the BotFather token and `allowFrom` user ID. |
| Telegram shows a saved configuration but cannot complete a live check | The token is saved. Confirm the gateway can reach `api.telegram.org`, or open **Settings → Channels → Telegram → Advanced → Network proxy** and enter an HTTP or SOCKS proxy. |
| Telegram rejects the token | Copy the current token from BotFather or regenerate it. |
| Telegram receives no messages | Confirm the channel is enabled, the gateway is running, and the sender is paired or listed in `allowFrom`. |
| Discord replies missing | Enable Message Content intent and invite the bot with the required permissions. |
| WhatsApp or WeChat login expired | Re-run `nanobot channels login whatsapp` or `nanobot channels login weixin`. |
| Chat app works but WebUI does not | The provider and gateway are likely fine; debug the WebSocket channel separately. |
+1
View File
@@ -8,6 +8,7 @@ from nanobot.channels.telegram.validation import validate
SETUP_SPEC = ChannelSetupSpec(
fields={
"token": field("secret"),
"proxy": field("secret"),
"allowFrom": field("list"),
"groupPolicy": field("enum", choices=GROUP_POLICIES, default="mention"),
},
@@ -4,11 +4,60 @@ import httpx
import pytest
from nanobot.channels.telegram import validation as telegram_validation
from nanobot.channels.telegram.manifest import SETUP_SPEC
from nanobot.channels.validation import validate_channel_config
from nanobot.config.loader import save_config
from nanobot.config.schema import Config
def test_telegram_setup_exposes_proxy_as_an_optional_secret() -> None:
proxy = SETUP_SPEC.fields["proxy"]
assert proxy.kind == "secret"
assert "proxy" not in SETUP_SPEC.simple_required_fields
def test_get_me_builds_http_client_with_explicit_proxy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
proxy = "socks5://proxy-user:proxy-pass@127.0.0.1:1080"
captured: dict[str, object] = {}
class FakeResponse:
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return {"ok": True, "result": {"id": 42}}
class FakeClient:
def __init__(self, **kwargs) -> None:
captured["kwargs"] = kwargs
def __enter__(self):
return self
def __exit__(self, *_args) -> None:
return None
def get(self, url: str) -> FakeResponse:
captured["url"] = url
return FakeResponse()
monkeypatch.setattr(telegram_validation.httpx, "Client", FakeClient)
result = telegram_validation._get_me(token, proxy)
assert result["ok"] is True
assert captured["kwargs"] == {
"timeout": 4.0,
"proxy": proxy,
"trust_env": False,
}
assert captured["url"] == f"https://api.telegram.org/bot{token}/getMe"
def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
@@ -21,7 +70,38 @@ def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.Mo
assert result["missing_fields"] == []
def test_validate_telegram_does_not_expose_saved_token_in_http_errors(
@pytest.mark.parametrize("status_code", [401, 404])
def test_validate_telegram_rejects_denied_tokens_without_exposing_them(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
status_code: int,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"telegram": {"token": token}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def raise_http_error(token_value: str, _proxy: str | None) -> dict:
request = httpx.Request("GET", f"https://api.telegram.org/bot{token_value}/getMe")
response = httpx.Response(status_code, request=request)
raise httpx.HTTPStatusError("rejected", request=request, response=response)
monkeypatch.setattr(telegram_validation, "_get_me", raise_http_error)
result = validate_channel_config("telegram", {"channels.telegram.token": ""})
assert result["status"] == "invalid"
assert result["can_enable"] is False
assert token not in str(result)
assert any(
f"HTTP {status_code}" in check.get("message", "") for check in result["checks"]
)
def test_validate_telegram_keeps_transient_http_failures_retryable(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -33,14 +113,197 @@ def test_validate_telegram_does_not_expose_saved_token_in_http_errors(
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def raise_http_error(url: str, **_kwargs) -> dict:
request = httpx.Request("GET", url)
response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
def raise_http_error(token_value: str, _proxy: str | None) -> dict:
request = httpx.Request("GET", f"https://api.telegram.org/bot{token_value}/getMe")
response = httpx.Response(503, request=request)
raise httpx.HTTPStatusError("unavailable", request=request, response=response)
monkeypatch.setattr(telegram_validation, "http_get", raise_http_error)
monkeypatch.setattr(telegram_validation, "_get_me", raise_http_error)
result = validate_channel_config("telegram", {"channels.telegram.token": ""})
assert result["status"] == "configured"
assert result["can_enable"] is True
assert token not in str(result)
assert any("HTTP 401" in check.get("message", "") for check in result["checks"])
assert any("HTTP 503" in check.get("message", "") for check in result["checks"])
def test_validate_telegram_marks_proxy_transport_failures_without_exposing_proxy(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
proxy = "http://proxy-user:proxy-pass@127.0.0.1:7890"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{"channels": {"telegram": {"token": token, "proxy": proxy}}}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def raise_proxy_error(_token: str, _proxy: str | None) -> dict:
raise httpx.ProxyError("proxy credentials rejected")
monkeypatch.setattr(telegram_validation, "_get_me", raise_proxy_error)
result = validate_channel_config("telegram")
assert result["status"] == "configured"
assert result["can_enable"] is True
assert proxy not in str(result)
assert any(check["id"] == "proxy_connection" for check in result["checks"])
def test_validate_telegram_uses_saved_proxy_without_exposing_it(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
proxy = "socks5://proxy-user:proxy-pass@127.0.0.1:1080"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{"channels": {"telegram": {"token": token, "proxy": proxy}}}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
captured: dict[str, str | None] = {}
def fake_get_me(token_value: str, proxy_value: str | None) -> dict:
captured.update(token=token_value, proxy=proxy_value)
return {"ok": True, "result": {"id": 42, "username": "working_bot"}}
monkeypatch.setattr(telegram_validation, "_get_me", fake_get_me)
result = validate_channel_config("telegram")
assert result["status"] == "connected"
assert captured == {"token": token, "proxy": proxy}
assert proxy not in str(result)
def test_validate_telegram_resolves_saved_secret_env_refs_without_exposing_them(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
token_ref = "${TELEGRAM_TOKEN_TEST}"
proxy_ref = "${TELEGRAM_PROXY_TEST}"
proxy = "http://proxy-user:proxy-pass@127.0.0.1:7890"
monkeypatch.setenv("TELEGRAM_TOKEN_TEST", token)
monkeypatch.setenv("TELEGRAM_PROXY_TEST", proxy)
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{"channels": {"telegram": {"token": token_ref, "proxy": proxy_ref}}}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
captured: dict[str, str | None] = {}
def fake_get_me(token_value: str, proxy_value: str | None) -> dict:
captured.update(token=token_value, proxy=proxy_value)
return {"ok": True, "result": {"id": 42, "username": "working_bot"}}
monkeypatch.setattr(telegram_validation, "_get_me", fake_get_me)
result = validate_channel_config("telegram")
assert result["status"] == "connected"
assert captured == {"token": token, "proxy": proxy}
assert token_ref not in str(result)
assert token not in str(result)
assert proxy_ref not in str(result)
assert proxy not in str(result)
def test_validate_telegram_rejects_unset_proxy_env_ref_without_connecting(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
proxy_ref = "${TELEGRAM_MISSING_PROXY_TEST}"
monkeypatch.delenv("TELEGRAM_MISSING_PROXY_TEST", raising=False)
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{"channels": {"telegram": {"token": token, "proxy": proxy_ref}}}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def fail_get_me(*_args) -> dict:
pytest.fail("an unresolved proxy reference must not fall back to direct access")
monkeypatch.setattr(telegram_validation, "_get_me", fail_get_me)
result = validate_channel_config("telegram")
assert result["status"] == "invalid"
assert result["can_enable"] is False
assert proxy_ref not in str(result)
assert any(check["id"] == "proxy_env" for check in result["checks"])
def test_validate_telegram_uses_proxy_submitted_with_new_token(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
proxy = "http://127.0.0.1:7890"
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
captured: dict[str, str | None] = {}
def fake_get_me(token_value: str, proxy_value: str | None) -> dict:
captured.update(token=token_value, proxy=proxy_value)
return {"ok": True, "result": {"id": 42, "username": "new_bot"}}
monkeypatch.setattr(telegram_validation, "_get_me", fake_get_me)
result = validate_channel_config(
"telegram",
{
"channels.telegram.token": token,
"channels.telegram.proxy": proxy,
},
)
assert result["status"] == "connected"
assert captured == {"token": token, "proxy": proxy}
@pytest.mark.parametrize("proxy", ["127.0.0.1:7890", "http://[", "http://localhost:not-a-port"])
def test_validate_telegram_rejects_invalid_proxy_without_trying_token(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
proxy: str,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def fail_get_me(*_args) -> dict:
pytest.fail("invalid proxy must stop before getMe")
monkeypatch.setattr(telegram_validation, "_get_me", fail_get_me)
result = validate_channel_config(
"telegram",
{
"channels.telegram.token": token,
"channels.telegram.proxy": proxy,
},
)
assert result["status"] == "invalid"
assert result["can_enable"] is False
assert proxy not in str(result)
assert any(check["id"] == "proxy_format" for check in result["checks"])
+82 -5
View File
@@ -2,24 +2,82 @@
import re
from typing import Any
from urllib.parse import urlparse
import httpx
from nanobot.channels.contracts import ChannelValidationContext
from nanobot.channels.validation import (
check,
http_get,
message_from_response,
payload,
required_checks,
status_from_checks,
string_value,
)
from nanobot.config.loader import resolve_env_refs
_TIMEOUT_SECONDS = 4.0
_SUPPORTED_PROXY_SCHEMES = {"http", "https", "socks5", "socks5h"}
def _proxy_url_is_valid(proxy: str) -> bool:
try:
parsed = urlparse(proxy)
hostname = parsed.hostname
parsed.port
except ValueError:
return False
return parsed.scheme.lower() in _SUPPORTED_PROXY_SCHEMES and bool(hostname)
def _get_me(token: str, proxy: str | None) -> dict[str, Any]:
client_kwargs: dict[str, Any] = {"timeout": _TIMEOUT_SECONDS}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
response = client.get(f"https://api.telegram.org/bot{token}/getMe")
response.raise_for_status()
data = response.json()
return data if isinstance(data, dict) else {}
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
checks, missing = required_checks("telegram", values)
token = string_value(values.get("token"))
raw_token = string_value(values.get("token"))
raw_proxy = string_value(values.get("proxy"))
token = string_value(resolve_env_refs(raw_token))
proxy = string_value(resolve_env_refs(raw_proxy))
if raw_token and not token:
checks.append(
check(
"token_env",
"Token environment variable",
"fail",
"Set every environment variable referenced by the bot token.",
)
)
if raw_proxy and not proxy:
checks.append(
check(
"proxy_env",
"Proxy environment variable",
"fail",
"Set every environment variable referenced by the network proxy.",
)
)
if (raw_token and not token) or (raw_proxy and not proxy):
return status_from_checks("telegram", checks, missing)
if proxy and not _proxy_url_is_valid(proxy):
checks.append(
check(
"proxy_format",
"Network proxy",
"fail",
"Enter a full HTTP or SOCKS proxy URL.",
)
)
return status_from_checks("telegram", checks, missing)
if token:
if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token):
checks.append(
@@ -35,7 +93,7 @@ def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict
check("token_format", "Token format", "pass", "Looks like a BotFather token.")
)
try:
data = http_get(f"https://api.telegram.org/bot{token}/getMe")
data = _get_me(token, proxy or None)
if data.get("ok") and isinstance(data.get("result"), dict):
bot = data["result"]
identity = {
@@ -61,12 +119,31 @@ def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict
)
)
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code
rejected = status_code in {400, 401, 403, 404}
checks.append(
check(
"get_me",
"Bot identity",
"fail" if rejected else "warn",
(
f"Telegram rejected the token: HTTP {status_code}."
if rejected
else f"Telegram could not verify the token: HTTP {status_code}."
),
)
)
except httpx.TransportError:
checks.append(
check(
"proxy_connection" if proxy else "get_me",
"Network proxy" if proxy else "Bot identity",
"warn",
f"Telegram could not verify the token: HTTP {exc.response.status_code}.",
(
"Could not reach Telegram through the network proxy."
if proxy
else "Could not reach Telegram now. Try again later."
),
)
)
except Exception:
@@ -75,7 +152,7 @@ def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict
"get_me",
"Bot identity",
"warn",
"Could not reach Telegram now. Try again later.",
"Could not verify Telegram now. Try again later.",
)
)
return status_from_checks("telegram", checks, missing)
+1
View File
@@ -12,6 +12,7 @@ export default {
docsUrl: chatAppGuideUrl("telegram"),
fields: [
{ key: "channels.telegram.token" },
{ key: "channels.telegram.proxy" },
{ key: "channels.telegram.allowFrom" },
{ key: "channels.telegram.groupPolicy" },
],
@@ -17,6 +17,10 @@
"placeholder": "123456:ABC...",
"help": "Create it with BotFather."
},
"proxy": {
"label": "Network proxy",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": {
"label": "Allowed users",
"placeholder": "* or Telegram user IDs",
@@ -17,6 +17,10 @@
"placeholder": "123456:ABC...",
"help": "Créalo con BotFather."
},
"proxy": {
"label": "Proxy de red",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": {
"label": "Usuarios permitidos",
"placeholder": "* o ID de usuario de Telegram",
@@ -17,6 +17,10 @@
"placeholder": "123456:ABC...",
"help": "Créez-le avec BotFather."
},
"proxy": {
"label": "Proxy réseau",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": {
"label": "Utilisateurs autorisés",
"placeholder": "* ou ID utilisateur Telegram",
@@ -17,6 +17,10 @@
"placeholder": "123456:ABC...",
"help": "Buat dengan BotFather."
},
"proxy": {
"label": "Proxy jaringan",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": {
"label": "Pengguna yang diizinkan",
"placeholder": "* atau ID pengguna Telegram",
@@ -17,6 +17,10 @@
"placeholder": "123456:ABC...",
"help": "BotFather で作成します。"
},
"proxy": {
"label": "ネットワークプロキシ",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": {
"label": "許可するユーザー",
"placeholder": "* または Telegram ユーザー ID",
@@ -17,6 +17,10 @@
"placeholder": "123456:ABC...",
"help": "BotFather에서 생성하세요."
},
"proxy": {
"label": "네트워크 프록시",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": {
"label": "허용된 사용자",
"placeholder": "* 또는 Telegram 사용자 ID",
@@ -17,6 +17,10 @@
"placeholder": "123456:ABC...",
"help": "Crie-o com o BotFather."
},
"proxy": {
"label": "Proxy de rede",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": {
"label": "Usuários permitidos",
"placeholder": "* ou IDs de usuário do Telegram",
@@ -17,6 +17,10 @@
"placeholder": "123456:ABC...",
"help": "Tạo bằng BotFather."
},
"proxy": {
"label": "Proxy mạng",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": {
"label": "Người dùng được phép",
"placeholder": "* hoặc ID người dùng Telegram",
@@ -17,6 +17,10 @@
"placeholder": "123456:ABC...",
"help": "使用 BotFather 创建。"
},
"proxy": {
"label": "网络代理",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": {
"label": "允许的用户",
"placeholder": "* 或 Telegram 用户 ID",
@@ -17,6 +17,10 @@
"placeholder": "123456:ABC...",
"help": "使用 BotFather 建立。"
},
"proxy": {
"label": "網路代理",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": {
"label": "允許的使用者",
"placeholder": "* 或 Telegram 使用者 ID",