diff --git a/modules/service_plugins/webhook_service.py b/modules/service_plugins/webhook_service.py new file mode 100644 index 0000000..3e55963 --- /dev/null +++ b/modules/service_plugins/webhook_service.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" +Inbound Webhook Service for MeshCore Bot + +Starts a lightweight HTTP server that accepts POST requests from external +systems and relays them as messages into MeshCore channels or DMs. + +Config section ``[Webhook]``:: + + enabled = false + host = 127.0.0.1 # bind address (0.0.0.0 to expose externally) + port = 8765 # listen port + secret_token = # if set, require Authorization: Bearer + allowed_channels = # comma-separated whitelist; empty = all channels + max_message_length = 200 # truncate messages exceeding this length + +HTTP API +-------- +POST /webhook + Content-Type: application/json + Authorization: Bearer (if secret_token is configured) + + Body (channel message):: + + {"channel": "general", "message": "Hello from webhook!"} + + Body (DM):: + + {"dm_to": "SomeUser", "message": "Private message"} + +Response codes: + 200 {"ok": true} + 400 {"error": "..."} bad/missing fields + 401 {"error": "Unauthorized"} wrong / missing token + 405 method not allowed +""" + +import secrets +from typing import Any, Optional + +from .base_service import BaseServicePlugin + +try: + from aiohttp import web as aio_web + AIOHTTP_AVAILABLE = True +except ImportError: + aio_web = None # type: ignore[assignment] + AIOHTTP_AVAILABLE = False + + +class WebhookService(BaseServicePlugin): + """Accept inbound HTTP POST webhooks and relay them as MeshCore messages.""" + + config_section = "Webhook" + description = "Inbound webhook receiver — relay HTTP POST payloads to MeshCore channels" + + # Maximum body size accepted (bytes) + MAX_BODY_SIZE = 8_192 + + def __init__(self, bot: Any) -> None: + super().__init__(bot) + + cfg = bot.config + + if not AIOHTTP_AVAILABLE: + self.logger.error( + "WebhookService requires aiohttp. Install it with: pip install aiohttp" + ) + self.enabled = False + return + + if not cfg.has_section("Webhook"): + self.enabled = False + return + + self.enabled = cfg.getboolean("Webhook", "enabled", fallback=False) + self.host = cfg.get("Webhook", "host", fallback="127.0.0.1").strip() + self.port = cfg.getint("Webhook", "port", fallback=8765) + self.secret_token: str = cfg.get("Webhook", "secret_token", fallback="").strip() + self.max_message_length: int = cfg.getint( + "Webhook", "max_message_length", fallback=200 + ) + + raw_channels = cfg.get("Webhook", "allowed_channels", fallback="").strip() + self.allowed_channels = ( + {c.strip().lstrip("#").lower() for c in raw_channels.split(",") if c.strip()} + if raw_channels + else set() + ) + + self._runner: Optional[Any] = None # aio_web.AppRunner + self._site: Optional[Any] = None # aio_web.TCPSite + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + if not self.enabled: + self.logger.info("Webhook service is disabled") + return + + app = aio_web.Application(client_max_size=self.MAX_BODY_SIZE) + app.router.add_post("/webhook", self._handle_webhook) + + self._runner = aio_web.AppRunner(app) + await self._runner.setup() + self._site = aio_web.TCPSite(self._runner, self.host, self.port) + await self._site.start() + + auth_info = "token auth enabled" if self.secret_token else "NO auth (secret_token not set)" + self.logger.info( + f"Webhook service listening on {self.host}:{self.port} ({auth_info})" + ) + self._running = True + + async def stop(self) -> None: + self._running = False + if self._runner: + await self._runner.cleanup() + self._runner = None + self._site = None + self.logger.info("Webhook service stopped") + + # ------------------------------------------------------------------ + # Request handler + # ------------------------------------------------------------------ + + async def _handle_webhook(self, request: Any) -> Any: + """Handle a POST /webhook request.""" + # --- Auth --- + if self.secret_token and not self._verify_token(request): + self.logger.warning( + f"Webhook: rejected unauthenticated request from {request.remote}" + ) + return aio_web.Response( + status=401, + content_type="application/json", + text='{"error": "Unauthorized"}', + ) + + # --- Parse body --- + try: + body = await request.json() + except Exception: + return aio_web.Response( + status=400, + content_type="application/json", + text='{"error": "Invalid JSON body"}', + ) + + message_text: str = str(body.get("message", "")).strip() + if not message_text: + return aio_web.Response( + status=400, + content_type="application/json", + text='{"error": "Missing required field: message"}', + ) + + # Truncate to configured limit + if len(message_text) > self.max_message_length: + message_text = message_text[: self.max_message_length] + + channel: str = str(body.get("channel", "")).strip().lstrip("#") + dm_to: str = str(body.get("dm_to", "")).strip() + + if not channel and not dm_to: + return aio_web.Response( + status=400, + content_type="application/json", + text='{"error": "Missing required field: channel or dm_to"}', + ) + + # --- Channel whitelist check --- + if channel and self.allowed_channels and channel.lower() not in self.allowed_channels: + self.logger.warning( + f"Webhook: channel '{channel}' not in allowed_channels whitelist" + ) + return aio_web.Response( + status=400, + content_type="application/json", + text='{"error": "Channel not allowed"}', + ) + + # --- Dispatch --- + try: + if channel: + await self._send_channel_message(channel, message_text) + self.logger.info( + f"Webhook: sent to #{channel} from {request.remote}: " + f"{message_text[:60]}{'...' if len(message_text) > 60 else ''}" + ) + else: + await self._send_dm(dm_to, message_text) + self.logger.info( + f"Webhook: sent DM to {dm_to} from {request.remote}: " + f"{message_text[:60]}{'...' if len(message_text) > 60 else ''}" + ) + except Exception as exc: + self.logger.error(f"Webhook: failed to send message: {exc}") + return aio_web.Response( + status=500, + content_type="application/json", + text='{"error": "Failed to send message"}', + ) + + return aio_web.Response( + status=200, + content_type="application/json", + text='{"ok": true}', + ) + + # ------------------------------------------------------------------ + # Auth helpers + # ------------------------------------------------------------------ + + def _verify_token(self, request: Any) -> bool: + """Return True if the request carries the correct bearer token.""" + auth_header: str = request.headers.get("Authorization", "") + if auth_header.lower().startswith("bearer "): + provided = auth_header[7:].strip() + return secrets.compare_digest(provided, self.secret_token) + # Also accept X-Webhook-Token header + x_token: str = request.headers.get("X-Webhook-Token", "").strip() + if x_token: + return secrets.compare_digest(x_token, self.secret_token) + return False + + # ------------------------------------------------------------------ + # Message dispatch + # ------------------------------------------------------------------ + + async def _send_channel_message(self, channel: str, message: str) -> None: + """Send a message to a MeshCore channel via command_manager.""" + cm = getattr(self.bot, "command_manager", None) + if cm is None: + raise RuntimeError("command_manager not available on bot") + await cm.send_channel_message( + channel, + message, + skip_user_rate_limit=True, + rate_limit_key=None, + ) + + async def _send_dm(self, recipient: str, message: str) -> None: + """Send a direct message via command_manager.""" + cm = getattr(self.bot, "command_manager", None) + if cm is None: + raise RuntimeError("command_manager not available on bot") + await cm.send_dm( + recipient, + message, + skip_user_rate_limit=True, + rate_limit_key=None, + ) diff --git a/tests/test_webhook_service.py b/tests/test_webhook_service.py new file mode 100644 index 0000000..cdc7188 --- /dev/null +++ b/tests/test_webhook_service.py @@ -0,0 +1,243 @@ +"""Tests for WebhookService.""" + +from configparser import ConfigParser +from unittest.mock import AsyncMock, Mock + +import pytest + +from modules.service_plugins.webhook_service import WebhookService + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + + +def _make_bot(mock_logger, extra_cfg=None): + """Return a minimal mock bot for WebhookService.""" + bot = Mock() + bot.logger = mock_logger + bot.config = ConfigParser() + bot.config.add_section("Webhook") + bot.config.set("Webhook", "enabled", "true") + bot.config.set("Webhook", "host", "127.0.0.1") + bot.config.set("Webhook", "port", "8765") + bot.config.set("Webhook", "secret_token", "") + bot.config.set("Webhook", "max_message_length", "200") + bot.config.set("Webhook", "allowed_channels", "") + if extra_cfg: + for key, val in extra_cfg.items(): + bot.config.set("Webhook", key, val) + bot.command_manager = Mock() + bot.command_manager.send_channel_message = AsyncMock(return_value=True) + bot.command_manager.send_dm = AsyncMock(return_value=True) + return bot + + +def _make_request(body=None, headers=None, remote="127.0.0.1"): + """Return a mock aiohttp Request.""" + req = Mock() + req.remote = remote + req.headers = headers or {} + req.json = AsyncMock(return_value=body or {}) + return req + + +def _make_service(mock_logger, extra_cfg=None): + bot = _make_bot(mock_logger, extra_cfg) + return WebhookService(bot), bot + + +# --------------------------------------------------------------------------- +# TestInit +# --------------------------------------------------------------------------- + + +class TestInit: + def test_enabled_reads_from_config(self, mock_logger): + svc, _ = _make_service(mock_logger) + assert svc.enabled is True + + def test_disabled_when_no_section(self, mock_logger): + bot = Mock() + bot.logger = mock_logger + bot.config = ConfigParser() + svc = WebhookService(bot) + assert svc.enabled is False + + def test_allowed_channels_parsed(self, mock_logger): + svc, _ = _make_service(mock_logger, {"allowed_channels": "general, alerts"}) + assert "general" in svc.allowed_channels + assert "alerts" in svc.allowed_channels + + def test_hash_stripped_from_channel_names(self, mock_logger): + svc, _ = _make_service(mock_logger, {"allowed_channels": "#general,#alerts"}) + assert "general" in svc.allowed_channels + assert "alerts" in svc.allowed_channels + + def test_empty_allowed_channels_means_all(self, mock_logger): + svc, _ = _make_service(mock_logger) + assert svc.allowed_channels == set() + + def test_secret_token_loaded(self, mock_logger): + svc, _ = _make_service(mock_logger, {"secret_token": "s3cr3t"}) + assert svc.secret_token == "s3cr3t" + + +# --------------------------------------------------------------------------- +# TestVerifyToken +# --------------------------------------------------------------------------- + + +class TestVerifyToken: + def test_bearer_token_accepted(self, mock_logger): + svc, _ = _make_service(mock_logger, {"secret_token": "abc123"}) + req = _make_request(headers={"Authorization": "Bearer abc123"}) + assert svc._verify_token(req) is True + + def test_wrong_bearer_rejected(self, mock_logger): + svc, _ = _make_service(mock_logger, {"secret_token": "abc123"}) + req = _make_request(headers={"Authorization": "Bearer wrong"}) + assert svc._verify_token(req) is False + + def test_x_webhook_token_accepted(self, mock_logger): + svc, _ = _make_service(mock_logger, {"secret_token": "abc123"}) + req = _make_request(headers={"X-Webhook-Token": "abc123"}) + assert svc._verify_token(req) is True + + def test_no_token_header_rejected(self, mock_logger): + svc, _ = _make_service(mock_logger, {"secret_token": "abc123"}) + req = _make_request(headers={}) + assert svc._verify_token(req) is False + + def test_case_insensitive_bearer_prefix(self, mock_logger): + svc, _ = _make_service(mock_logger, {"secret_token": "tok"}) + req = _make_request(headers={"Authorization": "BEARER tok"}) + assert svc._verify_token(req) is True + + +# --------------------------------------------------------------------------- +# TestHandleWebhook — auth +# --------------------------------------------------------------------------- + + +class TestHandleWebhookAuth: + @pytest.mark.asyncio + async def test_missing_token_returns_401(self, mock_logger): + svc, _ = _make_service(mock_logger, {"secret_token": "secret"}) + req = _make_request(body={"channel": "general", "message": "hi"}, headers={}) + resp = await svc._handle_webhook(req) + assert resp.status == 401 + + @pytest.mark.asyncio + async def test_correct_token_returns_200(self, mock_logger): + svc, _ = _make_service(mock_logger, {"secret_token": "secret"}) + req = _make_request( + body={"channel": "general", "message": "hi"}, + headers={"Authorization": "Bearer secret"}, + ) + resp = await svc._handle_webhook(req) + assert resp.status == 200 + + @pytest.mark.asyncio + async def test_no_token_required_when_secret_empty(self, mock_logger): + svc, _ = _make_service(mock_logger) # no secret_token + req = _make_request(body={"channel": "general", "message": "hi"}, headers={}) + resp = await svc._handle_webhook(req) + assert resp.status == 200 + + +# --------------------------------------------------------------------------- +# TestHandleWebhook — validation +# --------------------------------------------------------------------------- + + +class TestHandleWebhookValidation: + @pytest.mark.asyncio + async def test_invalid_json_returns_400(self, mock_logger): + svc, _ = _make_service(mock_logger) + req = _make_request() + req.json = AsyncMock(side_effect=Exception("bad json")) + resp = await svc._handle_webhook(req) + assert resp.status == 400 + + @pytest.mark.asyncio + async def test_missing_message_returns_400(self, mock_logger): + svc, _ = _make_service(mock_logger) + req = _make_request(body={"channel": "general"}) + resp = await svc._handle_webhook(req) + assert resp.status == 400 + + @pytest.mark.asyncio + async def test_missing_channel_and_dm_returns_400(self, mock_logger): + svc, _ = _make_service(mock_logger) + req = _make_request(body={"message": "hi"}) + resp = await svc._handle_webhook(req) + assert resp.status == 400 + + @pytest.mark.asyncio + async def test_disallowed_channel_returns_400(self, mock_logger): + svc, _ = _make_service(mock_logger, {"allowed_channels": "alerts"}) + req = _make_request(body={"channel": "general", "message": "hi"}) + resp = await svc._handle_webhook(req) + assert resp.status == 400 + + @pytest.mark.asyncio + async def test_allowed_channel_passes(self, mock_logger): + svc, _ = _make_service(mock_logger, {"allowed_channels": "general"}) + req = _make_request(body={"channel": "general", "message": "hi"}) + resp = await svc._handle_webhook(req) + assert resp.status == 200 + + +# --------------------------------------------------------------------------- +# TestHandleWebhook — dispatch +# --------------------------------------------------------------------------- + + +class TestHandleWebhookDispatch: + @pytest.mark.asyncio + async def test_channel_message_dispatched(self, mock_logger): + svc, bot = _make_service(mock_logger) + req = _make_request(body={"channel": "general", "message": "Hello!"}) + await svc._handle_webhook(req) + bot.command_manager.send_channel_message.assert_awaited_once() + call_args = bot.command_manager.send_channel_message.call_args + assert call_args[0][0] == "general" + assert call_args[0][1] == "Hello!" + + @pytest.mark.asyncio + async def test_hash_stripped_from_channel_in_body(self, mock_logger): + svc, bot = _make_service(mock_logger) + req = _make_request(body={"channel": "#general", "message": "Hello!"}) + await svc._handle_webhook(req) + call_args = bot.command_manager.send_channel_message.call_args + assert call_args[0][0] == "general" + + @pytest.mark.asyncio + async def test_dm_dispatched(self, mock_logger): + svc, bot = _make_service(mock_logger) + req = _make_request(body={"dm_to": "Alice", "message": "Hi Alice!"}) + await svc._handle_webhook(req) + bot.command_manager.send_dm.assert_awaited_once() + call_args = bot.command_manager.send_dm.call_args + assert call_args[0][0] == "Alice" + assert call_args[0][1] == "Hi Alice!" + + @pytest.mark.asyncio + async def test_long_message_truncated(self, mock_logger): + svc, bot = _make_service(mock_logger, {"max_message_length": "10"}) + long_msg = "A" * 100 + req = _make_request(body={"channel": "general", "message": long_msg}) + await svc._handle_webhook(req) + sent = bot.command_manager.send_channel_message.call_args[0][1] + assert len(sent) == 10 + + @pytest.mark.asyncio + async def test_send_failure_returns_500(self, mock_logger): + svc, bot = _make_service(mock_logger) + bot.command_manager.send_channel_message = AsyncMock( + side_effect=RuntimeError("mesh offline") + ) + req = _make_request(body={"channel": "general", "message": "hi"}) + resp = await svc._handle_webhook(req) + assert resp.status == 500