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.
This commit is contained in:
agessaman
2026-03-16 21:00:57 -07:00
parent 6512f2dc13
commit b72eaab171
4 changed files with 110 additions and 19 deletions

View File

@@ -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.<meshcore_channel> = <discord_webhook_url>
# Channel mappings: bridge.<meshcore_channel> = <discord_webhook_url>[, <discord_webhook_url>...]
# 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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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}