mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-05-30 04:14:02 +00:00
feat: inbound webhook relay with bearer token authentication
Add POST /webhook endpoint to the web viewer. Authenticated via Authorization: Bearer <token> set in [Webhook] config section. Relays JSON or text payload to a configured MeshCore channel or user DM.
This commit is contained in:
@@ -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 <token>
|
||||
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 <secret_token> (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,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user