From 9087e2ac8d7d1ae3eb0be0fbe38e0344d3d7b0b8 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 23:40:53 +0300 Subject: [PATCH] =?UTF-8?q?fix(bot):=20Telegram=20through=20TG=5FPROXY=5FU?= =?UTF-8?q?RL=20=E2=80=94=20docker=20on=20the=20RF=20server=20has=20no=20I?= =?UTF-8?q?Pv4=20route=20to=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #108 Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 ++ app/channels/telegram.py | 9 ++++++++- app/config.py | 2 ++ app/integrations/notify.py | 4 ++-- pyproject.toml | 1 + tests/test_telegram.py | 4 +++- tests/test_telegram_proxy.py | 16 ++++++++++++++++ uv.lock | 24 ++++++++++++++++++++++++ 8 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 tests/test_telegram_proxy.py diff --git a/.env.example b/.env.example index 9b90399..43c7fa1 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,8 @@ CLINICAL_DB_URL=postgresql+asyncpg://clinical_app:clinical_app@localhost:5432/cl # --- Telegram: prototype on synthetic data only; prod channel is MAX / Gosuslugi. Empty = bot disabled. TG_BOT_TOKEN= +# http://user:pass@host:port or socks5://… — needed on the RF server (docker has no route to Telegram over IPv4) +TG_PROXY_URL= DOCTOR_NOTIFY_CHAT_ID= # --- Demo virtual clock (ADR-005) diff --git a/app/channels/telegram.py b/app/channels/telegram.py index 486d36a..755dbcc 100644 --- a/app/channels/telegram.py +++ b/app/channels/telegram.py @@ -19,6 +19,7 @@ from typing import Protocol import yaml from aiogram import Bot, Dispatcher, F, Router +from aiogram.client.session.aiohttp import AiohttpSession from aiogram.filters import CommandObject, CommandStart from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message from pydantic import BaseModel @@ -272,6 +273,12 @@ _task: asyncio.Task[None] | None = None _prev_outbox: Outbox | None = None +def make_bot(token: str) -> Bot: + """aiogram `Bot`, through `TG_PROXY_URL` when it is set.""" + proxy = get_settings().tg_proxy_url + return Bot(token, session=AiohttpSession(proxy=proxy)) if proxy else Bot(token) + + async def start() -> None: """Start polling if TG_BOT_TOKEN is set; no-op otherwise.""" global _bot, _dp, _task, _prev_outbox @@ -280,7 +287,7 @@ async def start() -> None: if not token: log.info("telegram disabled: TG_BOT_TOKEN is empty") return - bot = Bot(token) + bot = make_bot(token) channel = TelegramChannel(bot) dp = Dispatcher() dp.include_router(build_router(channel)) diff --git a/app/config.py b/app/config.py index 9f7426a..27e5cb6 100644 --- a/app/config.py +++ b/app/config.py @@ -44,6 +44,8 @@ class Settings(BaseSettings): # Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi. tg_bot_token: str = "" + # The RF server reaches api.telegram.org over IPv6 only; docker has IPv4 only → go through a proxy (http/socks). + tg_proxy_url: str = "" doctor_notify_chat_id: int | None = None # MAX is opt-in. Its adapter is delivered in the related backend/channel changes. diff --git a/app/integrations/notify.py b/app/integrations/notify.py index 29c548f..248970b 100644 --- a/app/integrations/notify.py +++ b/app/integrations/notify.py @@ -92,9 +92,9 @@ class TelegramDoctorChat: def _get_sender(self) -> ChatSender: if self._sender is None: - from aiogram import Bot + from app.channels.telegram import make_bot - self._bot = Bot(self._bot_token) + self._bot = make_bot(self._bot_token) self._sender = self._bot.send_message return self._sender diff --git a/pyproject.toml b/pyproject.toml index e1afa3a..7d952b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "pandas>=2.2", "qrcode[pil]>=7.4", "tzdata>=2026.5", + "aiohttp-socks>=0.8", ] [dependency-groups] diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 77b9065..64ccd88 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -261,7 +261,9 @@ async def test_start_is_noop_without_token(monkeypatch: pytest.MonkeyPatch) -> N async def test_start_stop_swaps_outbox(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(telegram, "get_settings", lambda: type("S", (), {"tg_bot_token": "123:fake"})()) + monkeypatch.setattr( + telegram, "get_settings", lambda: type("S", (), {"tg_bot_token": "123:fake", "tg_proxy_url": ""})() + ) async def fake_polling(self: object, *bots: object, **kwargs: object) -> None: import asyncio diff --git a/tests/test_telegram_proxy.py b/tests/test_telegram_proxy.py new file mode 100644 index 0000000..b09ec48 --- /dev/null +++ b/tests/test_telegram_proxy.py @@ -0,0 +1,16 @@ +"""TG_PROXY_URL: the bot session goes through the proxy when it is set.""" + +from app.channels import telegram +from app.config import get_settings + +TOKEN = "123456:" + "A" * 35 + + +def test_make_bot_without_proxy(monkeypatch): + monkeypatch.setattr(get_settings(), "tg_proxy_url", "") + assert telegram.make_bot(TOKEN).session.proxy is None + + +def test_make_bot_with_proxy(monkeypatch): + monkeypatch.setattr(get_settings(), "tg_proxy_url", "http://user:pass@127.0.0.1:8888") + assert telegram.make_bot(TOKEN).session.proxy == "http://user:pass@127.0.0.1:8888" diff --git a/uv.lock b/uv.lock index fa2c0d6..74d9c09 100644 --- a/uv.lock +++ b/uv.lock @@ -122,6 +122,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, ] +[[package]] +name = "aiohttp-socks" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "python-socks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/1d/a306e0111222180e60f17131a3f5d9bc694dd999a8115959a7dd76c2238e/aiohttp_socks-0.12.0.tar.gz", hash = "sha256:3caf9f5a4164611122d412bc11b2f9114fd29c85e1ba27bb38060d3c236bdc8d", size = 12061, upload-time = "2026-08-12T04:43:15.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/64/ca6289632020523ea1841f01363f83ada10eeb0f21dd931fc1118dd85668/aiohttp_socks-0.12.0-py3-none-any.whl", hash = "sha256:ba6f95ec775c761d87f8578ab48f137d0457c676da104984202bf75e747d5ee6", size = 10693, upload-time = "2026-08-12T04:43:14.524Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -1075,6 +1088,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "aiogram" }, + { name = "aiohttp-socks" }, { name = "apscheduler" }, { name = "asyncpg" }, { name = "fastapi" }, @@ -1103,6 +1117,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiogram", specifier = ">=3.10" }, + { name = "aiohttp-socks", specifier = ">=0.8" }, { name = "apscheduler", specifier = ">=3.10,<4" }, { name = "asyncpg", specifier = ">=0.29" }, { name = "fastapi", specifier = ">=0.115" }, @@ -1340,6 +1355,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "python-socks" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/ad/484ffb79532517b11a90af38647c38652224650b31a7ae1cedd5a418d8ab/python_socks-3.1.1.tar.gz", hash = "sha256:8d3e817cdbe858dc0bb8c8fdc8e79b6ce37acce110d33374c6f57a675cc9029e", size = 232781, upload-time = "2026-09-08T13:03:31.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/23/2c2cef1b4313c55d1713201acd4ee2043fb2cf22e546ca9d36bf4317faea/python_socks-3.1.1-py3-none-any.whl", hash = "sha256:327e0d6378702c73a7790bf732e9f01392f17b48c7348a50b5bd1f710c2df1be", size = 49566, upload-time = "2026-09-08T13:03:29.604Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3"