fix(telegram): expose proxy setup in WebUI (#5033)
This commit is contained in:
+33
-1
@@ -109,7 +109,24 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b></summary>
|
<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
|
```bash
|
||||||
nanobot plugins enable telegram
|
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.
|
> 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.
|
> `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.
|
||||||
|
|||||||
@@ -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
|
This guide connects one Telegram bot to nanobot. Messages sent to that bot use
|
||||||
self-hosted AI agent backed by your normal nanobot config, tools, memory, and
|
your normal nanobot model, tools, memory, and workspace.
|
||||||
workspace.
|
|
||||||
|
|
||||||
## What this guide builds
|
## What this guide builds
|
||||||
|
|
||||||
@@ -29,27 +28,55 @@ python -m pip install nanobot-ai
|
|||||||
nanobot onboard --wizard
|
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
|
```bash
|
||||||
nanobot plugins enable telegram
|
nanobot plugins enable telegram
|
||||||
```
|
```
|
||||||
|
|
||||||
Merge this snippet into `~/.nanobot/config.json`:
|
Then merge this snippet into `~/.nanobot/config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"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
|
Omitting `allowFrom` enables pairing-only mode. The first DM from a new user
|
||||||
gets a pairing code instead of agent access.
|
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
|
- If the channel is not listed, run `nanobot plugins enable telegram` again in
|
||||||
the same Python environment.
|
the same Python environment.
|
||||||
- If messages do not arrive, run `nanobot gateway --verbose` and check the bot
|
- If the WebUI shows a saved configuration but the live check cannot reach Telegram,
|
||||||
token.
|
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
|
- If a first DM returns a pairing code, that is expected. Approve the code before
|
||||||
testing normal agent replies.
|
testing normal agent replies.
|
||||||
- If Telegram Web shows unsupported rich messages, keep `richMessages` disabled.
|
- If Telegram Web shows unsupported rich messages, keep `richMessages` disabled.
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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`. |
|
| 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. |
|
| Chat app works but WebUI does not | The provider and gateway are likely fine; debug the WebSocket channel separately. |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from nanobot.channels.telegram.validation import validate
|
|||||||
SETUP_SPEC = ChannelSetupSpec(
|
SETUP_SPEC = ChannelSetupSpec(
|
||||||
fields={
|
fields={
|
||||||
"token": field("secret"),
|
"token": field("secret"),
|
||||||
|
"proxy": field("secret"),
|
||||||
"allowFrom": field("list"),
|
"allowFrom": field("list"),
|
||||||
"groupPolicy": field("enum", choices=GROUP_POLICIES, default="mention"),
|
"groupPolicy": field("enum", choices=GROUP_POLICIES, default="mention"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,11 +4,60 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.channels.telegram import validation as telegram_validation
|
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.channels.validation import validate_channel_config
|
||||||
from nanobot.config.loader import save_config
|
from nanobot.config.loader import save_config
|
||||||
from nanobot.config.schema import 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:
|
def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
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"] == []
|
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,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> 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)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
def raise_http_error(url: str, **_kwargs) -> dict:
|
def raise_http_error(token_value: str, _proxy: str | None) -> dict:
|
||||||
request = httpx.Request("GET", url)
|
request = httpx.Request("GET", f"https://api.telegram.org/bot{token_value}/getMe")
|
||||||
response = httpx.Response(401, request=request)
|
response = httpx.Response(503, request=request)
|
||||||
raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
|
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": ""})
|
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 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"])
|
||||||
|
|||||||
@@ -2,24 +2,82 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from nanobot.channels.contracts import ChannelValidationContext
|
from nanobot.channels.contracts import ChannelValidationContext
|
||||||
from nanobot.channels.validation import (
|
from nanobot.channels.validation import (
|
||||||
check,
|
check,
|
||||||
http_get,
|
|
||||||
message_from_response,
|
message_from_response,
|
||||||
payload,
|
payload,
|
||||||
required_checks,
|
required_checks,
|
||||||
status_from_checks,
|
status_from_checks,
|
||||||
string_value,
|
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]:
|
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
|
||||||
checks, missing = required_checks("telegram", values)
|
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 token:
|
||||||
if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token):
|
if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token):
|
||||||
checks.append(
|
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.")
|
check("token_format", "Token format", "pass", "Looks like a BotFather token.")
|
||||||
)
|
)
|
||||||
try:
|
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):
|
if data.get("ok") and isinstance(data.get("result"), dict):
|
||||||
bot = data["result"]
|
bot = data["result"]
|
||||||
identity = {
|
identity = {
|
||||||
@@ -61,12 +119,31 @@ def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status_code = exc.response.status_code
|
||||||
|
rejected = status_code in {400, 401, 403, 404}
|
||||||
checks.append(
|
checks.append(
|
||||||
check(
|
check(
|
||||||
"get_me",
|
"get_me",
|
||||||
"Bot identity",
|
"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",
|
"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:
|
except Exception:
|
||||||
@@ -75,7 +152,7 @@ def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict
|
|||||||
"get_me",
|
"get_me",
|
||||||
"Bot identity",
|
"Bot identity",
|
||||||
"warn",
|
"warn",
|
||||||
"Could not reach Telegram now. Try again later.",
|
"Could not verify Telegram now. Try again later.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return status_from_checks("telegram", checks, missing)
|
return status_from_checks("telegram", checks, missing)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default {
|
|||||||
docsUrl: chatAppGuideUrl("telegram"),
|
docsUrl: chatAppGuideUrl("telegram"),
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "channels.telegram.token" },
|
{ key: "channels.telegram.token" },
|
||||||
|
{ key: "channels.telegram.proxy" },
|
||||||
{ key: "channels.telegram.allowFrom" },
|
{ key: "channels.telegram.allowFrom" },
|
||||||
{ key: "channels.telegram.groupPolicy" },
|
{ key: "channels.telegram.groupPolicy" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Create it with BotFather."
|
"help": "Create it with BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Network proxy",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Allowed users",
|
"label": "Allowed users",
|
||||||
"placeholder": "* or Telegram user IDs",
|
"placeholder": "* or Telegram user IDs",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Créalo con BotFather."
|
"help": "Créalo con BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Proxy de red",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Usuarios permitidos",
|
"label": "Usuarios permitidos",
|
||||||
"placeholder": "* o ID de usuario de Telegram",
|
"placeholder": "* o ID de usuario de Telegram",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Créez-le avec BotFather."
|
"help": "Créez-le avec BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Proxy réseau",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Utilisateurs autorisés",
|
"label": "Utilisateurs autorisés",
|
||||||
"placeholder": "* ou ID utilisateur Telegram",
|
"placeholder": "* ou ID utilisateur Telegram",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Buat dengan BotFather."
|
"help": "Buat dengan BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Proxy jaringan",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Pengguna yang diizinkan",
|
"label": "Pengguna yang diizinkan",
|
||||||
"placeholder": "* atau ID pengguna Telegram",
|
"placeholder": "* atau ID pengguna Telegram",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "BotFather で作成します。"
|
"help": "BotFather で作成します。"
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "ネットワークプロキシ",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "許可するユーザー",
|
"label": "許可するユーザー",
|
||||||
"placeholder": "* または Telegram ユーザー ID",
|
"placeholder": "* または Telegram ユーザー ID",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "BotFather에서 생성하세요."
|
"help": "BotFather에서 생성하세요."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "네트워크 프록시",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "허용된 사용자",
|
"label": "허용된 사용자",
|
||||||
"placeholder": "* 또는 Telegram 사용자 ID",
|
"placeholder": "* 또는 Telegram 사용자 ID",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Crie-o com o BotFather."
|
"help": "Crie-o com o BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Proxy de rede",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Usuários permitidos",
|
"label": "Usuários permitidos",
|
||||||
"placeholder": "* ou IDs de usuário do Telegram",
|
"placeholder": "* ou IDs de usuário do Telegram",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Tạo bằng BotFather."
|
"help": "Tạo bằng BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Proxy mạng",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Người dùng được phép",
|
"label": "Người dùng được phép",
|
||||||
"placeholder": "* hoặc ID người dùng Telegram",
|
"placeholder": "* hoặc ID người dùng Telegram",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "使用 BotFather 创建。"
|
"help": "使用 BotFather 创建。"
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "网络代理",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "允许的用户",
|
"label": "允许的用户",
|
||||||
"placeholder": "* 或 Telegram 用户 ID",
|
"placeholder": "* 或 Telegram 用户 ID",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "使用 BotFather 建立。"
|
"help": "使用 BotFather 建立。"
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "網路代理",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "允許的使用者",
|
"label": "允許的使用者",
|
||||||
"placeholder": "* 或 Telegram 使用者 ID",
|
"placeholder": "* 或 Telegram 使用者 ID",
|
||||||
|
|||||||
Reference in New Issue
Block a user