From b72eaab171d7666f1fa0e2485849ae839176ee09 Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 16 Mar 2026 21:00:57 -0700 Subject: [PATCH] Enhance Discord bridge configuration to support multiple webhooks per channel - Updated config.ini.example and discord-bridge.md to reflect the ability to fan out a single MeshCore channel to multiple Discord servers using a comma- or whitespace-separated list of webhook URLs. - Modified DiscordBridgeService to handle multiple webhooks per channel, including validation and logging improvements for better monitoring of configured webhooks. --- config.ini.example | 5 +- docs/discord-bridge.md | 3 + .../service_plugins/discord_bridge_service.py | 57 +++++++++++------ tests/test_discord_bridge_multi_webhooks.py | 64 +++++++++++++++++++ 4 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 tests/test_discord_bridge_multi_webhooks.py diff --git a/config.ini.example b/config.ini.example index 1c778fb..38031f1 100644 --- a/config.ini.example +++ b/config.ini.example @@ -1524,7 +1524,7 @@ avatar_style = color # Bridge the bot's own channel responses (e.g. command replies) to Discord. true (default) = bridge; false = only bridge other users' messages # bridge_bot_responses = true -# Channel mappings: bridge. = +# Channel mappings: bridge. = [, ...] # Only channels explicitly listed here will be bridged to Discord # Get webhook URLs from Discord: Channel Settings → Integrations → Webhooks → Create Webhook # @@ -1537,6 +1537,9 @@ avatar_style = color # bridge.general = https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRST # bridge.emergency = https://discord.com/api/webhooks/987654321098765432/ZYXWVUTSRQPONMLKJIHGFEDCBA0987654321zyxwvutsrqponmlkjihgf # bridge.wx = https://discord.com/api/webhooks/111222333444555666/WeatherChannelWebhookTokenGoesHere1234567890abcdefghijklmno +# +# Fan out a single MeshCore channel to multiple Discord servers +# bridge.Public = https://discord.com/api/webhooks/AAA/aaa..., https://discord.com/api/webhooks/BBB/bbb... [TelegramBridge] # Enable Telegram bridge service diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 561cccf..a2d9896 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -61,6 +61,9 @@ avatar_style = color # Map MeshCore channels to Discord webhooks bridge.general = https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz1234567890ABCD bridge.emergency = https://discord.com/api/webhooks/987654321098765432/ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgf + +# Fan out a single MeshCore channel to multiple Discord servers +bridge.Public = https://discord.com/api/webhooks/AAA/aaa..., https://discord.com/api/webhooks/BBB/bbb... ``` **Important:** Only channels explicitly listed will be bridged. Any channel not in the config will be ignored. diff --git a/modules/service_plugins/discord_bridge_service.py b/modules/service_plugins/discord_bridge_service.py index 10de3b8..6ee5f19 100644 --- a/modules/service_plugins/discord_bridge_service.py +++ b/modules/service_plugins/discord_bridge_service.py @@ -83,7 +83,8 @@ class DiscordBridgeService(BaseServicePlugin): return # Load channel mappings from config (bridge.* pattern) - self.channel_webhooks: Dict[str, str] = {} + # Map MeshCore channel name → list of Discord webhook URLs + self.channel_webhooks: Dict[str, list] = {} self._load_channel_mappings() # NEVER bridge DMs (hardcoded for privacy) @@ -150,24 +151,41 @@ class DiscordBridgeService(BaseServicePlugin): self.logger.warning("No [DiscordBridge] section found in config") return + import re + for key, value in self.bot.config.items('DiscordBridge'): # Look for bridge.* pattern if key.startswith('bridge.'): channel_name = key[7:] # Remove 'bridge.' prefix - webhook_url = value.strip() + raw_value = value.strip() - # Basic validation - if not webhook_url.startswith('https://discord.com/api/webhooks/'): - self.logger.warning(f"Invalid webhook URL for channel '{channel_name}': {webhook_url[:50]}...") + if not raw_value: continue - # Store with original case, but we'll match case-insensitively - self.channel_webhooks[channel_name] = webhook_url - # Mask webhook token in logs for security - masked_url = self._mask_webhook_url(webhook_url) - self.logger.info(f"Configured Discord bridge: {channel_name} → {masked_url}") + # Support multiple webhooks per channel via comma- or whitespace-separated list + # URLs themselves never contain spaces or commas, so this is safe + candidates = [part.strip() for part in re.split(r'[,\s]+', raw_value) if part.strip()] - self.logger.info(f"Loaded {len(self.channel_webhooks)} Discord channel mapping(s)") + for webhook_url in candidates: + # Basic validation + if not webhook_url.startswith('https://discord.com/api/webhooks/'): + self.logger.warning(f"Invalid webhook URL for channel '{channel_name}': {webhook_url[:50]}...") + continue + + # Initialize list for channel if needed + if channel_name not in self.channel_webhooks: + self.channel_webhooks[channel_name] = [] + + self.channel_webhooks[channel_name].append(webhook_url) + + # Mask webhook token in logs for security + masked_url = self._mask_webhook_url(webhook_url) + self.logger.info(f"Configured Discord bridge: {channel_name} → {masked_url}") + + total_mappings = sum(len(urls) for urls in self.channel_webhooks.values()) + self.logger.info( + f"Loaded {len(self.channel_webhooks)} Discord channel(s) with {total_mappings} webhook mapping(s)" + ) def _generate_avatar_url(self, username: str) -> Optional[str]: """Generate a unique avatar URL for a username. @@ -291,9 +309,10 @@ class DiscordBridgeService(BaseServicePlugin): self.logger.info("Registered for bot channel-sent events (bridge_bot_responses=true)") # Initialize message queues for each webhook - for webhook_url in self.channel_webhooks.values(): - self.message_queues[webhook_url] = deque() - self.send_times[webhook_url] = deque() + for webhook_urls in self.channel_webhooks.values(): + for webhook_url in webhook_urls: + self.message_queues[webhook_url] = deque() + self.send_times[webhook_url] = deque() # Start background queue processor task self._queue_processor_task = asyncio.create_task(self._process_message_queues()) @@ -369,15 +388,15 @@ class DiscordBridgeService(BaseServicePlugin): return # Check if this channel is configured for bridging (case-insensitive) - webhook_url = None + webhook_urls = None matched_config_name = None for config_channel, url in self.channel_webhooks.items(): if config_channel.lower() == channel_name.lower(): - webhook_url = url + webhook_urls = url matched_config_name = config_channel break - if not webhook_url: + if not webhook_urls: self.logger.debug(f"Channel '{channel_name}' not configured for Discord bridge") return @@ -406,7 +425,9 @@ class DiscordBridgeService(BaseServicePlugin): message_text = censor(message_text, self.logger) # Queue message for posting (with rate limiting and retry logic) - await self._queue_message(webhook_url, message_text, channel_name, sender_name) + # Fan out to all configured webhooks for this channel + for webhook_url in webhook_urls: + await self._queue_message(webhook_url, message_text, channel_name, sender_name) except Exception as e: self.logger.error(f"Error handling mesh channel message: {e}", exc_info=True) diff --git a/tests/test_discord_bridge_multi_webhooks.py b/tests/test_discord_bridge_multi_webhooks.py new file mode 100644 index 0000000..4b8ea53 --- /dev/null +++ b/tests/test_discord_bridge_multi_webhooks.py @@ -0,0 +1,64 @@ +"""Tests for DiscordBridgeService multi-webhook channel fan-out.""" + +import asyncio +from configparser import ConfigParser +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +from modules.service_plugins.discord_bridge_service import DiscordBridgeService + + +@pytest.fixture +def multi_webhook_bot(mock_logger): + """Mock bot for DiscordBridgeService multi-webhook tests.""" + bot = MagicMock() + bot.logger = mock_logger + bot.config = ConfigParser() + bot.config.add_section("DiscordBridge") + bot.channel_manager = MagicMock() + bot.channel_manager.get_channel_name.return_value = "Public" + bot.channel_sent_listeners = [] + bot.meshcore = MagicMock() + return bot + + +@pytest.mark.asyncio +async def test_single_webhook_backward_compatible(multi_webhook_bot): + """Single webhook value still behaves as before.""" + url = "https://discord.com/api/webhooks/123/abc" + multi_webhook_bot.config.set("DiscordBridge", "enabled", "true") + multi_webhook_bot.config.set("DiscordBridge", "bridge.Public", url) + + service = DiscordBridgeService(multi_webhook_bot) + + assert service.channel_webhooks == {"Public": [url]} + + +@pytest.mark.asyncio +async def test_multiple_webhooks_parsed_and_queued(multi_webhook_bot): + """Comma-separated webhooks for a channel are all used.""" + url1 = "https://discord.com/api/webhooks/123/abc" + url2 = "https://discord.com/api/webhooks/456/def" + multi_webhook_bot.config.set("DiscordBridge", "enabled", "true") + multi_webhook_bot.config.set( + "DiscordBridge", + "bridge.Public", + f"{url1}, {url2}", + ) + + service = DiscordBridgeService(multi_webhook_bot) + + # Both URLs should be registered for the same channel + assert service.channel_webhooks == {"Public": [url1, url2]} + + # Patch queue_message to observe fan-out behaviour + with patch.object(service, "_queue_message", new_callable=AsyncMock) as mock_queue: + event = MagicMock() + event.payload = {"channel_idx": 0, "text": "Alice: hello world"} + await service._on_mesh_channel_message(event) + + # One call per webhook URL + assert mock_queue.await_count == 2 + called_urls = {call.args[0] for call in mock_queue.await_args_list} + assert called_urls == {url1, url2}