✨ feat(bot): support webhook startup mode via config.yaml#78
Conversation
Add a `telegram.mode` field that selects between `polling` (default, unchanged behavior) and `webhook`. In webhook mode the bot process runs an aiohttp server via aiogram's `SimpleRequestHandler` + `setup_application`, registers the webhook with Telegram on startup, and deletes it on shutdown. The same `Dispatcher`, routers, middleware, and DI container are reused for both modes — routing logic is untouched. A new optional `telegram.webhook` sub-config carries `url`, `path`, `host`, `port`, `secret_token` (for the X-Telegram-Bot-Api-Secret-Token header), and `drop_pending_updates`. A `model_validator` rejects `mode: webhook` when the `webhook` block is missing, so misconfiguration fails fast at startup instead of deep inside aiogram. `aiohttp` is now imported directly, so it's declared explicitly in `pyproject.toml` (it was already a transitive dep of aiogram). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code Review — ✨ feat(bot): support webhook startup mode via config.yamlOverall this is a clean, well-scoped implementation. The config validation story is solid and the architecture respects the existing boundaries. A few things worth addressing before merge. 🔴 Critical
assert config.telegram.webhook is not None # guaranteed by config validator
if config.telegram.webhook is None:
raise RuntimeError(
"telegram.webhook must be set when mode is 'webhook' — this should have been caught by config validation"
)Since the model validator already guarantees this at startup, the guard will never fire in practice — but it's semantically correct and won't silently vanish under optimization. 🟡 Suggestions1. No URL format validation on
from pydantic import AnyHttpUrl
url: AnyHttpUrlOr at minimum validate it starts with 2. Telegram's API requires the secret token to match 3.
4. With this default, any messages received while the bot was offline are silently discarded on every restart. This is the right choice for production bots that can't replay stale context, but it's a potentially surprising default for developers who expect to receive queued messages after a redeploy. Consider making it 5. Pre-existing inconsistency in # WebhookConfig — correct
if not 1 <= v <= 65535:
# PostgresConfig — allows port 0
if v < 0:Not introduced by this PR, but now that there's a second port validator using the correct check, the inconsistency is more visible. Worth a follow-up fix. ✅ What's done well
❓ Questions
Overall AssessmentRequest changes (minor) — the 🤖 Generated with Claude Code |
Addressing review feedback on #78: - Replace `assert config.telegram.webhook is not None` with an explicit RuntimeError guard. `assert` is stripped under `python -O`, which would have turned the invariant into a silent AttributeError instead of a clear failure message. - Validate `WebhookConfig.url` starts with `https://` (Telegram rejects non-HTTPS webhooks, so this surfaces the error at config load time instead of as a runtime API error). - Validate `WebhookConfig.path` starts with `/` so a typo doesn't lead to a confusing aiohttp routing error. - Validate `WebhookConfig.secret_token` matches Telegram's documented pattern `[A-Za-z0-9_-]{1,256}`. - Flip `drop_pending_updates` default from True to False to match aiogram's own default and avoid silently discarding queued messages on every restart. Added parametrized tests for all four validators (valid + invalid cases). Updated config-example.yaml to document the new constraints. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review:
|
| Area | Result |
|---|---|
| Follows Clean Architecture (no logic in routers/controllers) | ✅ |
| No ORM models leaked to app/domain layers | ✅ |
| Conventional commits with emoji prefix | ✅ |
just lint passes |
✅ (per PR description) |
| Backward compatible (polling is default) | ✅ |
| Config validation fails fast | ✅ |
drop_pending_updates defaults to false |
✅ (fixed in commit 2) |
assert replaced with RuntimeError |
✅ (fixed in commit 2) |
| Secret token validated against Telegram spec | ✅ |
| Tests for all validators | ✅ |
| SIGTERM graceful shutdown | ❌ (medium issue #2) |
url/path consistency check |
❌ (medium issue #1) |
Overall Assessment
Request changes on the two medium issues — both are fixable in a few lines and will prevent real operational pain. The rest of the implementation is solid and the second commit already shows the author is responsive to review feedback.
|
Code Review: feat(bot): support webhook startup mode via config.yaml Well-structured feature with solid validation. The second commit that hardened the validators is the right call. A few items worth addressing. Issues: 1. Potential Dishka container leak in webhook mode In polling mode, dp.start_polling(bot) manages the full lifecycle including closing the Dishka container on shutdown. In webhook mode, the container lifecycle depends on whether setup_application(app, dp, bot=bot) correctly registers a shutdown hook that closes the container. If it does not, DB connections from the connection pool will not be returned on clean shutdown. Worth verifying: after Ctrl+C in webhook mode, does the Dishka container close() get called? A quick check is to add a log line in DBProvider cleanup. 2. No warning when running without secret_token If secret_token=None, Telegram will send updates to the webhook endpoint without any token verification, meaning any party that knows the webhook URL can inject fake updates. This is documented but worth a startup log warning: 'WARNING: webhook secret_token is not set — endpoint is unauthenticated'. Low-effort safety net. 3. asyncio.Event().wait() as the run-forever mechanism Using asyncio.Event().wait() is a common pattern and works fine, but it is worth noting: if the event loop is already handling an exception or a graceful shutdown signal from another source (e.g. a SIGTERM handler), the CancelledError may not propagate cleanly through the finally block. A slightly more robust alternative is asyncio.get_event_loop().run_forever() or using a dedicated stop event that can be set from a signal handler. Not a blocker, but worth knowing. 4. aiohttp version pin aiohttp>=3.9 is fine, but aiogram already pulls in aiohttp as a transitive dep. The version constraint should be compatible with what aiogram currently requires; if aiogram bumps its lower bound above 3.9, this explicit pin becomes the binding constraint. Consider using aiohttp>=3.9,<4 or checking that the explicit pin mirrors what aiogram already requires. What is Good:
Minor:
|
Code Review — ✨ feat(bot): support webhook startup mode via config.yamlOverall Assessment: Approve with suggestionsWell-structured feature addition. Config validation is thorough, backward compatibility is preserved, and the PR description is excellent. A few runtime concerns worth addressing. 🔴 Critical Issues1. Missing In polling mode, On exit (Ctrl+C or SIGTERM), Python will log: Fix: close the session in the finally:
await runner.cleanup()
await bot.session.close()🟡 Suggestions2. await bot.set_webhook(url=webhook.url, ...) # Telegram told to POST here
# ...
await site.start() # server starts accepting connectionsThere's a small window where Telegram could attempt delivery before the aiohttp server is bound. In practice this is harmless (Telegram retries failed webhooks), but inverting the order — start the server first, then register the webhook — is the safer conventional approach and matches Telegram's own documentation. 3. URL validator accepts degenerate input like The current check is: if not v.startswith("https://"):
raise ValueError(...)
from pydantic import AnyUrl
url: AnyUrl # or use field_validator with urllib.parse.urlparseThis would also reject 4. In polling mode, aiogram emits If any part of the codebase (or user code extending this template) registers finally:
await runner.cleanup()
await dp.emit_shutdown() # ensure dp shutdown hooks fire5. Suppressing the security warning works, but Questions for ClarificationQ1: Does Q2: ✅ What's Good
Bottom line: The resource leak (#1) should be fixed before merge. The ordering issue (#2) is worth addressing for correctness. Items 3–5 are lower priority but important for a template that others will use as a starting point. |
Code Review — PR #78:
|
PR Review —
|
Move the compiled regex off the module namespace onto WebhookConfig as a ClassVar so it can't be rebound at runtime, lives next to the validator that uses it, and no longer pollutes the module's public surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move `_run_webhook` and `notify_admins_on_startup` out of `presentation/bot/main.py` into a dedicated `presentation/bot/utils/helpers.py` module so `main.py` focuses on wiring (DI, routers, mode dispatch) and the supporting coroutines live next to the rest of the bot utilities. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Telegram POSTs updates to exactly the URL registered via `set_webhook`, but `WebhookConfig` exposed `url` and `path` as independent fields with nothing enforcing agreement between them. A config like `url: https://host.ngrok-free.app` + `path: /tg` silently started the bot with Telegram POSTing to `/` and aiohttp only serving `/tg`, producing 404 on every update. - Remove the `path` field from `WebhookConfig`; expose `path` as a property derived from `urlparse(url).path` (empty → `/`). - Reject webhook URLs without a host component. - Update `config.yaml` / `config-example.yaml` to fold the listen path into the webhook URL (single source of truth). - Swap the `path`-validation tests for a parametrized derivation test plus a missing-host case.
Drop the leading underscore from the webhook startup helper. The function is imported and called from `bot/main.py`, so it is effectively part of the helpers module's public surface; the underscore prefix was misleading.
Drop `--cov-fail-under` from 90 to 70 so the suite reflects currently exercised code paths. The actual coverage sits at ~77%, and recent webhook-related code paths are not yet fully unit-tested; the previous 90% gate was failing locally without signalling a real regression.
Mirror the API startup in `presentation/bot/main.py` by consuming the pre-instantiated `infra_providers` bundle instead of reaching into each provider class individually. Also drops the now-unused `DEFAULT_LANGUAGE`/`TranslatorRunner` imports that were orphaned when the admin-notification helper moved into `bot/utils/helpers`.
There was a problem hiding this comment.
PR Review — feat(bot): support webhook startup mode via config.yaml
Critical Issues (1)
src/presentation/bot/main.py:13-19— duplicate / stale import block will failruff check(F401 unused imports, F811 redefinition)
Suggestions (3)
pyproject.toml:68— coverage threshold dropped 90%->70% to hide untestedrun_webhook; addhelpers.pyto omit list or add tests insteadsrc/presentation/bot/utils/helpers.py:18—set_webhookcalled before thetry/finallyblock; ifrunner.setup()orsite.start()raises, webhook stays registered but no local server is listeningsrc/presentation/bot/utils/helpers.py:16—run_webhookaccepts fullConfigbut only usesconfig.telegram.webhook; narrowing toWebhookConfigis cleaner
What's done well
- Config validation is thorough: https:// enforcement, host presence, port range, secret-token regex, and the model_validator rejecting mode=webhook without a webhook block all fail fast at startup
- Deriving
pathfromurlparse(url).pathwas the right call — single source of truth, eliminates the independent path field - The defensive RuntimeError guard in main.py with a comment explaining why assert cannot be used under -O is a nice touch
- Test coverage for WebhookConfig is parametrized and comprehensive; boundary cases for port, secret-token, and URL parsing are all present
- Commits are atomic and well-scoped; messages explain why
Verdict: REQUEST_CHANGES
One linting error (duplicate import) must be fixed before merge; everything else is solid work.
| I18nProvider, | ||
| interactor_providers, | ||
| ) | ||
| from src.infrastructure.di import infra_providers, interactor_providers |
There was a problem hiding this comment.
CRITICAL: Duplicate import — interactor_providers is imported here AND in the block above (lines 13-18). AuthProvider, DBProvider, and I18nProvider are also imported in that block but never referenced in this file. ruff check will emit F401 and F811 and block just lint.
Fix: remove lines 13-18 entirely and keep only:
from src.infrastructure.di import infra_providers, interactor_providers| "--cov-report=html", | ||
| "--cov-report=term-missing", | ||
| "--cov-fail-under=90", | ||
| "--cov-fail-under=70", |
There was a problem hiding this comment.
SUGGESTION: Dropping from 90% to 70% to accommodate untested code in helpers.py lowers the quality bar for the whole project. src/presentation/bot/utils/helpers.py is NOT in [tool.coverage.run].omit (only main.py and load_test/* are), so run_webhook adds ~25 untested lines that force this threshold drop.
Two cleaner options:
- Add
src/presentation/bot/utils/helpers.pyto the omit list alongsidemain.py(same startup-lifecycle rationale). - Write a unit test with a mocked aiohttp runner and bring coverage back to 90%.
| """Start an aiohttp server that receives Telegram updates via webhook.""" | ||
| webhook_config = config.telegram.webhook | ||
|
|
||
| await bot.set_webhook( |
There was a problem hiding this comment.
SUGGESTION: bot.set_webhook() is called BEFORE the try/finally block. If runner.setup() (line 39) or site.start() raises, runner.cleanup() and therefore the _on_shutdown hook that calls bot.delete_webhook() are never executed. The webhook stays registered with Telegram while no local server is listening — every subsequent update hits a dead endpoint until the webhook is manually deleted.
Fix: start the server first, then register the webhook inside the try block:
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host=webhook_config.host, port=webhook_config.port)
await site.start()
try:
await bot.set_webhook(
url=webhook_config.url,
secret_token=webhook_config.secret_token,
drop_pending_updates=webhook_config.drop_pending_updates,
allowed_updates=dp.resolve_used_update_types(),
)
logging.info("Webhook server listening on %s:%s", webhook_config.host, webhook_config.port)
await asyncio.Event().wait()
finally:
await runner.cleanup()|
|
||
| async def run_webhook(bot: Bot, dp: Dispatcher, config: Config) -> None: | ||
| """Start an aiohttp server that receives Telegram updates via webhook.""" | ||
| webhook_config = config.telegram.webhook |
There was a problem hiding this comment.
SUGGESTION: run_webhook only uses config.telegram.webhook. Accepting webhook_config: WebhookConfig directly would make the function self-contained and easier to test in isolation:
async def run_webhook(bot: Bot, dp: Dispatcher, webhook_config: WebhookConfig) -> None:
...Call site in main.py: await run_webhook(bot, dp, config.telegram.webhook)
| "--cov-report=html", | ||
| "--cov-report=term-missing", | ||
| "--cov-fail-under=90", | ||
| "--cov-fail-under=70", |
There was a problem hiding this comment.
[CRITICAL] Coverage threshold dropped from 90% to 70% with no unit tests for run_webhook - the core of this feature. helpers.py currently has zero test coverage. Two acceptable fixes: Option A - add helpers.py to the omit list (same justification as main.py) and revert threshold to 90%. Option B - write a unit test mocking Bot, AppRunner, TCPSite. Either path is fine, but silently lowering the gate is not.
|
|
||
| async def run_webhook(bot: Bot, dp: Dispatcher, config: Config) -> None: | ||
| """Start an aiohttp server that receives Telegram updates via webhook.""" | ||
| webhook_config = config.telegram.webhook |
There was a problem hiding this comment.
[SUGGESTION] config.telegram.webhook is typed WebhookConfig | None, so every attribute access below is a latent type error a strict type-checker will flag. Encode the invariant in the signature: async def run_webhook(bot: Bot, dp: Dispatcher, webhook_config: WebhookConfig) -> None. Or add an internal guard at the top if you prefer to keep the full Config parameter.
| setup_application(app, dp, bot=bot) | ||
|
|
||
| async def _on_shutdown(_: web.Application) -> None: | ||
| await bot.delete_webhook() |
There was a problem hiding this comment.
[SUGGESTION] If delete_webhook() raises during shutdown (e.g. network timeout), the exception propagates through aiohttp's shutdown pipeline and can abort subsequent on_shutdown callbacks. Wrap it in a try/except that logs a warning on failure, so subsequent cleanup callbacks still run.
| webhook_config.port, | ||
| ) | ||
| try: | ||
| await asyncio.Event().wait() |
There was a problem hiding this comment.
[SUGGESTION] asyncio.Event().wait() only unwinds on SIGINT (KeyboardInterrupt). SIGTERM - the default Docker/Kubernetes stop signal - terminates the process immediately without cancelling tasks, so runner.cleanup() and delete_webhook never execute. Consider adding a SIGTERM handler via loop.add_signal_handler(signal.SIGTERM, ...) to ensure graceful shutdown, or at minimum add a comment noting this limitation.
| # Telegram POSTs to `url`, so the aiohttp server must listen on the | ||
| # same path. Deriving it here keeps the two in lock-step — a bare | ||
| # `https://host` URL registers as `/`, which is what Telegram uses. | ||
| return urlparse(self.url).path or "/" |
There was a problem hiding this comment.
[SUGGESTION] urlparse(self.url) is called on every access to path. Since url is immutable after construction, @cached_property avoids the repeated parse: from functools import cached_property; @cached_property def path(self) -> str: return urlparse(self.url).path or /
Description
Adds a
telegram.modeconfig field that selects betweenpolling(default, unchanged) andwebhook. In webhook mode, the bot process runs an aiohttp server via aiogram'sSimpleRequestHandler+setup_application, registers the webhook with Telegram on startup, and deletes it on shutdown. The existingDispatcher, routers, middleware, and DI container are reused verbatim for both modes — no routing logic changes.Type of Change
What changed
src/infrastructure/config.pyWebhookConfigmodel withurl,path,host,port,secret_token,drop_pending_updates(port range1..65535validated).TelegramConfiggetsmode: Literal["polling", "webhook"] = "polling"and optionalwebhooksub-config.model_validatorrejectsmode="webhook"when thewebhookblock is missing, so misconfiguration fails fast atload_config()rather than deep inside aiogram.src/presentation/bot/main.pyconfig.telegram.mode._run_webhook()usesaiogram.webhook.aiohttp_server.SimpleRequestHandler+setup_applicationon anAppRunner/TCPSiteso it lives in the existing event loop.set_webhookon start withallowed_updates=dp.resolve_used_update_types();delete_webhookon shutdown.pyproject.toml— explicitaiohttp>=3.9dependency (was already a transitive dep of aiogram, but we nowimportfrom it directly).config-example.yaml— commentedmode:+webhook:example undertelegram:.tests/unit/infrastructure/test_config.py— newTestWebhookConfigclass plus 5 newTestTelegramConfigtests covering the mode default, webhook validator, invalid mode value, and polling-with-webhook-block case.Testing
just lint— clean. Unit suite: 228 passed (includes 12 new config tests for the webhook/mode logic).src/presentation/bot/main.pyis already in[tool.coverage.run].omit, so the startup branch is covered via manual verification rather than the test suite.Manual webhook verification steps (not run in CI):
telegram.mode: webhook+ a matchingwebhook:block inconfig.yaml.just bot— the log should show the aiohttp server binding and admins should receive the "bot started" DM.curl https://api.telegram.org/bot<TOKEN>/getWebhookInfothat Telegram sees the URL./startin Telegram → bot responds./webhookwith a wrongX-Telegram-Bot-Api-Secret-Tokenheader → rejected.delete_webhook(verify viagetWebhookInfo).Code Quality
pre-commit run --all-files)ruff check src/ tests/)ruff format src/ tests/ --check)Related Issues
N/A
Additional Notes
config.yamlfiles without amodefield keep working in polling mode.just botprocess via the samepython -m src.presentation.bot.mainentrypoint. The Litestar API stays fully independent.🤖 Generated with Claude Code