mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-06-03 14:24:03 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user