diff --git a/modules/core.py b/modules/core.py index befba7d..be320ba 100644 --- a/modules/core.py +++ b/modules/core.py @@ -1990,6 +1990,20 @@ long_jokes = false self.web_viewer_integration.start_viewer() self.logger.info("Web viewer started (early, before radio connect)") + # Start the inbound webhook service early too (before radio connect). Unlike + # the other service plugins, this one accepts inbound connections — starting + # it late leaves a window (proportional to how long radio connect() takes) + # where external callers get connection-refused instead of a clear response. + # The handler itself gates on self.connected and returns 503 until the mesh + # link is actually ready, so it's safe to bind before we're connected. + webhook_service = self.services.get('webhook') + if webhook_service is not None and getattr(webhook_service, 'enabled', False): + try: + await webhook_service.start() + self.logger.info("Service 'webhook' started (early, before radio connect)") + except Exception as e: + self.logger.error(f"Failed to start service 'webhook' early: {e}") + # Connect to MeshCore node if not await self.connect(): self.logger.error("Failed to connect to MeshCore node") @@ -2032,8 +2046,10 @@ long_jokes = false # Send startup advert if enabled await self.send_startup_advert() - # Start all loaded services + # Start all loaded services (webhook already started early, before radio connect) for service_name, service_instance in self.services.items(): + if service_name == 'webhook': + continue try: await service_instance.start() self.logger.info(f"Service '{service_name}' started") diff --git a/modules/service_plugins/webhook_service.py b/modules/service_plugins/webhook_service.py index 4320ece..a86ecaf 100644 --- a/modules/service_plugins/webhook_service.py +++ b/modules/service_plugins/webhook_service.py @@ -37,6 +37,11 @@ Response codes: 400 {"error": "..."} bad/missing fields 401 {"error": "Unauthorized"} wrong / missing token 405 method not allowed + 429 {"error": "Rate limit exceeded"} + 503 {"error": "Bot not yet connected to mesh, try again shortly"} + The listener starts before the radio connection is established, so + callers may see this briefly after service start; retry after a + few seconds. """ import secrets @@ -177,6 +182,18 @@ class WebhookService(BaseServicePlugin): text='{"error": "Rate limit exceeded"}', ) + # --- Readiness --- + # The listener starts before the mesh connection is established (see + # core.py), so reject requests with a clear, retryable error until the + # bot is actually connected, rather than accepting them only to fail + # message dispatch. + if not getattr(self.bot, "connected", False): + return aio_web.Response( + status=503, + content_type="application/json", + text='{"error": "Bot not yet connected to mesh, try again shortly"}', + ) + # --- Auth --- if self.secret_token and not self._verify_token(request): self.logger.warning( diff --git a/tests/test_webhook_service.py b/tests/test_webhook_service.py index d228b2a..e290c58 100644 --- a/tests/test_webhook_service.py +++ b/tests/test_webhook_service.py @@ -30,6 +30,7 @@ def _make_bot(mock_logger, extra_cfg=None): bot.command_manager = Mock() bot.command_manager.send_channel_message = AsyncMock(return_value=True) bot.command_manager.send_dm = AsyncMock(return_value=True) + bot.connected = True return bot @@ -115,6 +116,57 @@ class TestVerifyToken: assert svc._verify_token(req) is True +# --------------------------------------------------------------------------- +# TestHandleWebhook — readiness +# --------------------------------------------------------------------------- + + +class TestHandleWebhookReadiness: + @pytest.mark.asyncio + async def test_not_connected_returns_503(self, mock_logger): + svc, bot = _make_service(mock_logger) + bot.connected = False + req = _make_request(body={"channel": "general", "message": "hi"}) + resp = await svc._handle_webhook(req) + assert resp.status == 503 + + @pytest.mark.asyncio + async def test_not_connected_does_not_dispatch(self, mock_logger): + svc, bot = _make_service(mock_logger) + bot.connected = False + req = _make_request(body={"channel": "general", "message": "hi"}) + await svc._handle_webhook(req) + bot.command_manager.send_channel_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_connected_true_proceeds_normally(self, mock_logger): + svc, bot = _make_service(mock_logger) + bot.connected = True + req = _make_request(body={"channel": "general", "message": "hi"}) + resp = await svc._handle_webhook(req) + assert resp.status == 200 + + @pytest.mark.asyncio + async def test_missing_connected_attr_defaults_to_not_ready(self, mock_logger): + """If `bot.connected` isn't present at all, fail safe (503) rather than assume readiness.""" + svc, bot = _make_service(mock_logger) + del bot.connected + # Mock() raises AttributeError for deleted attrs, so getattr(..., False) applies. + req = _make_request(body={"channel": "general", "message": "hi"}) + resp = await svc._handle_webhook(req) + assert resp.status == 503 + + @pytest.mark.asyncio + async def test_rate_limit_checked_before_readiness(self, mock_logger): + """Rate limiting should still apply even while the bot isn't connected yet.""" + svc, bot = _make_service(mock_logger, {"rate_limit_per_minute": "1"}) + bot.connected = False + req = _make_request(body={"channel": "general", "message": "hi"}) + await svc._handle_webhook(req) + resp = await svc._handle_webhook(req) + assert resp.status == 429 + + # --------------------------------------------------------------------------- # TestHandleWebhook — auth # ---------------------------------------------------------------------------