feat(discord-bridge): neutralize mentions in bridged messages and suppress notifications

- Implemented functionality to prevent Discord mention notifications for `@everyone`, `@here`, roles, and users when relaying messages from MeshCore.
- Added a new method to neutralize Discord mention content, ensuring that mentions are displayed as plain text without triggering notifications.
- Updated the Discord webhook payload to include `allowed_mentions` settings to suppress mention parsing.
- Enhanced tests to verify the correct behavior of mention neutralization and webhook payload structure.
This commit is contained in:
agessaman
2026-05-30 21:54:27 -07:00
parent 837aaa778b
commit 9c2ff19520
5 changed files with 101 additions and 6 deletions
+2
View File
@@ -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
+23 -1
View File
@@ -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:
@@ -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:
+36
View File
@@ -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
@@ -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