mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-03-30 12:05:38 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
64
tests/test_discord_bridge_multi_webhooks.py
Normal file
64
tests/test_discord_bridge_multi_webhooks.py
Normal 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}
|
||||
Reference in New Issue
Block a user