diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index a2d9896..c636b62 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -137,6 +137,8 @@ seriously, there are some people... MeshCore @ mentions are cleaned up and bolded: `@[username]` → `**@username**` +Bridged messages never trigger Discord mention notifications (`@everyone`, `@here`, roles, or users). The webhook sets `allowed_mentions` and neutralizes `@` in message text so mesh highlights stay plain text (no blue mention tags). + --- ## Troubleshooting diff --git a/modules/bridge_outbound.py b/modules/bridge_outbound.py index 51c4f4d..70b44de 100644 --- a/modules/bridge_outbound.py +++ b/modules/bridge_outbound.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio import logging +import re from typing import Any, Optional try: @@ -28,6 +29,10 @@ except ImportError: REQUESTS_AVAILABLE = False DISCORD_WEBHOOK_PREFIX = "https://discord.com/api/webhooks/" +# Suppress @everyone/@here/role/user pings when relaying mesh text to Discord. +DISCORD_WEBHOOK_ALLOWED_MENTIONS: dict[str, list[str]] = {"parse": []} +# Zero-width space after @ — Discord still renders @name but won't treat it as a mention. +_DISCORD_MENTION_ZWSP = "\u200b" DISCORD_CONTENT_MAX = 2000 TELEGRAM_TEXT_MAX = 4096 TELEGRAM_TRUNCATE_AT = 4000 @@ -39,6 +44,22 @@ def is_valid_discord_webhook_url(url: str) -> bool: return bool(u.startswith(DISCORD_WEBHOOK_PREFIX)) +def neutralize_discord_mention_content(text: str) -> str: + """Break @ tokens so Discord webhooks do not create mention tags or pings.""" + if not text: + return text + # Native Discord mention syntax pasted from mesh → plain labels + text = re.sub(r"<@!?(\d+)>", r"@user-\1", text) + text = re.sub(r"<@&(\d+)>", r"@role-\1", text) + # Insert ZWSP after every @ not already neutralized (@everyone, **@admins**, etc.) + text = re.sub( + rf"@(?!{re.escape(_DISCORD_MENTION_ZWSP)})", + "@" + _DISCORD_MENTION_ZWSP, + text, + ) + return text + + def _truncate_discord_content(content: str) -> str: if len(content) <= DISCORD_CONTENT_MAX: return content @@ -66,8 +87,9 @@ async def post_discord_webhook( return False payload = { - "content": _truncate_discord_content(content), + "content": _truncate_discord_content(neutralize_discord_mention_content(content)), "username": username[:80] if username else "MeshCore", + "allowed_mentions": DISCORD_WEBHOOK_ALLOWED_MENTIONS, } if AIOHTTP_AVAILABLE: diff --git a/modules/service_plugins/discord_bridge_service.py b/modules/service_plugins/discord_bridge_service.py index 8f70a2b..b432e20 100644 --- a/modules/service_plugins/discord_bridge_service.py +++ b/modules/service_plugins/discord_bridge_service.py @@ -35,6 +35,10 @@ except ImportError: # Import base service import contextlib +from ..bridge_outbound import ( + DISCORD_WEBHOOK_ALLOWED_MENTIONS, + neutralize_discord_mention_content, +) from ..profanity_filter import censor, contains_profanity from ..security_utils import sanitize_name from .base_service import BaseServicePlugin @@ -44,7 +48,7 @@ from .base_service import BaseServicePlugin class QueuedMessage: """Represents a message queued for Discord posting.""" webhook_url: str - payload: dict[str, str] + payload: dict[str, Any] channel_name: str retry_count: int = 0 first_queued: float = 0.0 # Timestamp when first queued @@ -420,6 +424,7 @@ class DiscordBridgeService(BaseServicePlugin): # Clean up MeshCore @ mentions: @[username] → **@username** message_text = self._format_mentions(message_text) + message_text = neutralize_discord_mention_content(message_text) # Profanity filter: drop (don't bridge), censor (replace with ****), or off if self.filter_profanity == 'drop': @@ -457,7 +462,8 @@ class DiscordBridgeService(BaseServicePlugin): payload = { "content": message, - "username": username + "username": username, + "allowed_mentions": DISCORD_WEBHOOK_ALLOWED_MENTIONS, } if avatar_url: @@ -582,7 +588,7 @@ class DiscordBridgeService(BaseServicePlugin): self.logger.error(f"Error in message queue processor: {e}", exc_info=True) await asyncio.sleep(1.0) # Wait a bit before retrying on error - async def _post_to_webhook(self, webhook_url: str, payload: dict[str, str], channel_name: str, queued_msg: Optional[QueuedMessage] = None) -> bool: + async def _post_to_webhook(self, webhook_url: str, payload: dict[str, Any], channel_name: str, queued_msg: Optional[QueuedMessage] = None) -> bool: """Post message to Discord webhook. Args: @@ -608,7 +614,7 @@ class DiscordBridgeService(BaseServicePlugin): self.logger.error(f"Failed to post to Discord webhook [{channel_name}]: {e}", exc_info=True) return False - async def _post_async(self, webhook_url: str, payload: dict[str, str], channel_name: str, queued_msg: Optional[QueuedMessage] = None) -> bool: + async def _post_async(self, webhook_url: str, payload: dict[str, Any], channel_name: str, queued_msg: Optional[QueuedMessage] = None) -> bool: """Post to webhook using aiohttp (async). Args: @@ -660,7 +666,7 @@ class DiscordBridgeService(BaseServicePlugin): self.logger.error(f"Error posting to Discord webhook [{channel_name}]: {e}") return False - async def _post_sync(self, webhook_url: str, payload: dict[str, str], channel_name: str, queued_msg: Optional[QueuedMessage] = None) -> bool: + async def _post_sync(self, webhook_url: str, payload: dict[str, Any], channel_name: str, queued_msg: Optional[QueuedMessage] = None) -> bool: """Post to webhook using requests library (sync fallback). Args: diff --git a/tests/test_bridge_outbound.py b/tests/test_bridge_outbound.py index 91baf2b..e8650dd 100644 --- a/tests/test_bridge_outbound.py +++ b/tests/test_bridge_outbound.py @@ -7,6 +7,17 @@ import pytest from modules import bridge_outbound +def test_neutralize_discord_mention_content_everyone(): + out = bridge_outbound.neutralize_discord_mention_content("**@everyone** alert") + assert bridge_outbound._DISCORD_MENTION_ZWSP in out + assert "everyone" in out + + +def test_neutralize_discord_mention_content_idempotent(): + once = bridge_outbound.neutralize_discord_mention_content("@here") + assert once == bridge_outbound.neutralize_discord_mention_content(once) + + def test_is_valid_discord_webhook_url(): assert bridge_outbound.is_valid_discord_webhook_url( "https://discord.com/api/webhooks/123/abc-token" @@ -35,6 +46,29 @@ async def test_post_discord_webhook_async_success(): logger=MagicMock(), ) assert ok is True + _, kwargs = mock_session.post.call_args + assert kwargs["json"]["allowed_mentions"] == bridge_outbound.DISCORD_WEBHOOK_ALLOWED_MENTIONS + + +@pytest.mark.asyncio +async def test_post_discord_webhook_neutralizes_at_everyone(): + mock_resp = AsyncMock() + mock_resp.status = 204 + mock_session = MagicMock() + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=mock_resp) + cm.__aexit__ = AsyncMock(return_value=None) + mock_session.post.return_value = cm + + await bridge_outbound.post_discord_webhook( + "https://discord.com/api/webhooks/1/tok", + "@everyone test", + session=mock_session, + logger=MagicMock(), + ) + content = mock_session.post.call_args.kwargs["json"]["content"] + zwsp = bridge_outbound._DISCORD_MENTION_ZWSP + assert f"@{zwsp}everyone" in content @pytest.mark.asyncio @@ -85,3 +119,5 @@ async def test_post_discord_requests_fallback(): ) assert ok is True p.assert_called_once() + body = p.call_args.kwargs["json"] + assert body["allowed_mentions"] == bridge_outbound.DISCORD_WEBHOOK_ALLOWED_MENTIONS diff --git a/tests/test_discord_bridge_multi_webhooks.py b/tests/test_discord_bridge_multi_webhooks.py index 703e205..36fc27a 100644 --- a/tests/test_discord_bridge_multi_webhooks.py +++ b/tests/test_discord_bridge_multi_webhooks.py @@ -5,6 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from modules import bridge_outbound +from modules.bridge_outbound import DISCORD_WEBHOOK_ALLOWED_MENTIONS from modules.service_plugins.discord_bridge_service import DiscordBridgeService @@ -62,3 +64,30 @@ async def test_multiple_webhooks_parsed_and_queued(multi_webhook_bot): assert mock_queue.await_count == 2 called_urls = {call.args[0] for call in mock_queue.await_args_list} assert called_urls == {url1, url2} + + +@pytest.mark.asyncio +async def test_queue_message_includes_allowed_mentions(multi_webhook_bot): + """Webhook payload disables Discord mention parsing (no @everyone pings).""" + 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) + await service._queue_message( + url, + bridge_outbound.neutralize_discord_mention_content("**@everyone** alert"), + "Public", + "Alice", + ) + + queued = service.message_queues[url][0] + assert queued.payload["allowed_mentions"] == DISCORD_WEBHOOK_ALLOWED_MENTIONS + + +def test_format_mentions_then_neutralize_no_bare_at_everyone(multi_webhook_bot): + service = DiscordBridgeService(multi_webhook_bot) + formatted = service._format_mentions("@[everyone] hi") + assert formatted == "**@everyone** hi" + neutral = bridge_outbound.neutralize_discord_mention_content(formatted) + assert "**@" + bridge_outbound._DISCORD_MENTION_ZWSP + "everyone**" in neutral