mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-23 06:12:19 +00:00
feat(services): add Discord/Telegram outbound helpers and repeater discovery alerts
Add bridge_outbound posting, BaseServicePlugin.send_external_notifications, and RepeaterPrefixCollision discovery vs collision routing with silence_mesh_output.
This commit is contained in:
1 parent
2183f8e246
commit
dfa5dc2e01
6 files changed
+687
-3
No files matched your search
@@ -3,16 +3,43 @@
|
||||
Base service plugin class for background services
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalNotifySettings:
|
||||
"""Parsed outbound notification targets from the plugin config section."""
|
||||
|
||||
discord_urls: list[str]
|
||||
telegram_chat_ids: list[str]
|
||||
telegram_token: Optional[str]
|
||||
|
||||
|
||||
class BaseServicePlugin(ABC):
|
||||
"""Base class for background service plugins.
|
||||
|
||||
This class defines the interface for service plugins, which are long-running
|
||||
background tasks that can interact with the bot and mesh network. It manages
|
||||
service lifecycle (start/stop) and metadata.
|
||||
|
||||
**Optional outbound notifications (Discord / Telegram)** — read from this
|
||||
plugin's config section (``config_section``):
|
||||
|
||||
- ``discord_webhook_urls`` — comma-separated Discord webhook URLs
|
||||
(``https://discord.com/api/webhooks/...``).
|
||||
- ``telegram_chat_ids`` — comma-separated chat IDs or ``@channel`` usernames.
|
||||
- ``telegram_bot_token`` — optional; else ``TELEGRAM_BOT_TOKEN`` env, then
|
||||
``[TelegramBridge] api_token``.
|
||||
|
||||
**Mesh silence convention:** ``silence_mesh_output`` (default false). Services
|
||||
that both transmit mesh channel messages and call ``send_external_notifications``
|
||||
should skip ``send_channel_message`` when this is true so alerts go only to
|
||||
webhook/Telegram. Subclasses implement the guard at their mesh send sites.
|
||||
"""
|
||||
|
||||
# Optional: Config section name (if different from class name)
|
||||
@@ -35,6 +62,132 @@ class BaseServicePlugin(ABC):
|
||||
self.logger = bot.logger
|
||||
self.enabled = True
|
||||
self._running = False
|
||||
self._external_notify_cache: Optional[ExternalNotifySettings] = None
|
||||
|
||||
def _resolve_telegram_token_for_section(self, section: str) -> Optional[str]:
|
||||
import os
|
||||
|
||||
if self.bot.config.has_section(section):
|
||||
t = (self.bot.config.get(section, "telegram_bot_token", fallback="") or "").strip()
|
||||
if t:
|
||||
return t
|
||||
t = (os.environ.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||
if t:
|
||||
return t
|
||||
if self.bot.config.has_section("TelegramBridge"):
|
||||
t = (self.bot.config.get("TelegramBridge", "api_token", fallback="") or "").strip()
|
||||
if t:
|
||||
return t
|
||||
return None
|
||||
|
||||
def _parse_external_notify_settings(self) -> ExternalNotifySettings:
|
||||
from modules.bridge_outbound import is_valid_discord_webhook_url
|
||||
|
||||
section = self.config_section or self._derive_config_section()
|
||||
discord_raw = ""
|
||||
telegram_raw = ""
|
||||
if self.bot.config.has_section(section):
|
||||
discord_raw = (self.bot.config.get(section, "discord_webhook_urls", fallback="") or "").strip()
|
||||
telegram_raw = (self.bot.config.get(section, "telegram_chat_ids", fallback="") or "").strip()
|
||||
|
||||
discord_urls = [
|
||||
u.strip()
|
||||
for u in discord_raw.split(",")
|
||||
if u.strip() and is_valid_discord_webhook_url(u.strip())
|
||||
]
|
||||
telegram_chat_ids = [t.strip() for t in telegram_raw.split(",") if t.strip()]
|
||||
telegram_token = self._resolve_telegram_token_for_section(section)
|
||||
|
||||
return ExternalNotifySettings(
|
||||
discord_urls=discord_urls,
|
||||
telegram_chat_ids=telegram_chat_ids,
|
||||
telegram_token=telegram_token,
|
||||
)
|
||||
|
||||
def _get_external_notify_settings(self) -> ExternalNotifySettings:
|
||||
if self._external_notify_cache is None:
|
||||
self._external_notify_cache = self._parse_external_notify_settings()
|
||||
return self._external_notify_cache
|
||||
|
||||
def has_external_notification_targets(self) -> bool:
|
||||
"""True if Discord URLs are set, or Telegram chats plus a resolved bot token."""
|
||||
s = self._get_external_notify_settings()
|
||||
if s.discord_urls:
|
||||
return True
|
||||
return bool(s.telegram_chat_ids and s.telegram_token)
|
||||
|
||||
def _external_notify_discord_username(self) -> str:
|
||||
label = self.config_section or self._derive_config_section()
|
||||
return (label or "MeshCore")[:80]
|
||||
|
||||
async def send_external_notifications(self, text: str, *, discord_username: Optional[str] = None) -> None:
|
||||
"""Send text to configured Discord webhooks and Telegram chats.
|
||||
|
||||
No-op when no URLs/chat IDs are configured. Logs per-target failures;
|
||||
does not raise. Uses aiohttp with one shared session when available.
|
||||
"""
|
||||
from modules import bridge_outbound
|
||||
|
||||
settings = self._get_external_notify_settings()
|
||||
if not settings.discord_urls and not settings.telegram_chat_ids:
|
||||
return
|
||||
|
||||
user = discord_username if discord_username is not None else self._external_notify_discord_username()
|
||||
|
||||
if bridge_outbound.AIOHTTP_AVAILABLE:
|
||||
import aiohttp
|
||||
|
||||
aws: list[Any] = []
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for url in settings.discord_urls:
|
||||
aws.append(
|
||||
bridge_outbound.post_discord_webhook(
|
||||
url, text, username=user, session=session, logger=self.logger
|
||||
)
|
||||
)
|
||||
tok = settings.telegram_token
|
||||
if tok:
|
||||
for cid in settings.telegram_chat_ids:
|
||||
aws.append(
|
||||
bridge_outbound.post_telegram_message(
|
||||
tok, cid, text, session=session, logger=self.logger
|
||||
)
|
||||
)
|
||||
elif settings.telegram_chat_ids:
|
||||
self.logger.warning(
|
||||
"telegram_chat_ids set but no Telegram bot token "
|
||||
"(set telegram_bot_token, TELEGRAM_BOT_TOKEN, or [TelegramBridge] api_token)"
|
||||
)
|
||||
|
||||
if aws:
|
||||
results = await asyncio.gather(*aws, return_exceptions=True)
|
||||
for r in results:
|
||||
if isinstance(r, Exception):
|
||||
self.logger.warning("External notification error: %s", r, exc_info=True)
|
||||
return
|
||||
|
||||
for url in settings.discord_urls:
|
||||
try:
|
||||
await bridge_outbound.post_discord_webhook(
|
||||
url, text, username=user, session=None, logger=self.logger
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning("Discord webhook failed: %s", e, exc_info=True)
|
||||
|
||||
tok = settings.telegram_token
|
||||
if tok:
|
||||
for cid in settings.telegram_chat_ids:
|
||||
try:
|
||||
await bridge_outbound.post_telegram_message(
|
||||
tok, cid, text, session=None, logger=self.logger
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning("Telegram failed: %s", e, exc_info=True)
|
||||
elif settings.telegram_chat_ids:
|
||||
self.logger.warning(
|
||||
"telegram_chat_ids set but no Telegram bot token "
|
||||
"(set telegram_bot_token, TELEGRAM_BOT_TOKEN, or [TelegramBridge] api_token)"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
async def start(self) -> None:
|
||||
|
||||
@@ -4,6 +4,14 @@ Repeater Prefix Collision Service for MeshCore Bot
|
||||
|
||||
Watches NEW_CONTACT events and notifies channels when a newly discovered repeater
|
||||
shares a prefix with an existing repeater (configurable: 1/2/3 bytes).
|
||||
|
||||
Optional Discord/Telegram via ``send_external_notifications`` (see BaseServicePlugin):
|
||||
|
||||
- ``notify_external_on_all_new_repeaters`` — when true, sends a discovery message to
|
||||
webhook/Telegram for every qualified new repeater/roomserver; collision detail stays
|
||||
on mesh only (unless ``silence_mesh_output``).
|
||||
- ``silence_mesh_output`` — when true, collision alerts are not sent on mesh channels;
|
||||
discovery/collision externals follow ``notify_external_on_all_new_repeaters``.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -64,19 +72,30 @@ class RepeaterPrefixCollisionService(BaseServicePlugin):
|
||||
)
|
||||
self._notified: dict[_NotifyKey, float] = {} # key -> last_sent_epoch_seconds
|
||||
self._prefix_cooldown: dict[tuple[int, str], float] = {} # (bytes, prefix_hex_lower) -> last_sent_epoch_seconds
|
||||
self._discovery_notified: dict[str, float] = {} # public_key -> last discovery external epoch
|
||||
self._dedupe_prune_max_age_seconds = max(
|
||||
3600.0,
|
||||
float(self.cooldown_minutes_per_prefix) * 120.0,
|
||||
)
|
||||
|
||||
self.notify_external_on_all_new_repeaters = self.bot.config.getboolean(
|
||||
section, "notify_external_on_all_new_repeaters", fallback=False
|
||||
)
|
||||
self.silence_mesh_output = self.bot.config.getboolean(
|
||||
section, "silence_mesh_output", fallback=False
|
||||
)
|
||||
|
||||
self._running = False
|
||||
self._handler_installed = False
|
||||
self._handler_lock = asyncio.Lock()
|
||||
|
||||
self.logger.info(
|
||||
"RepeaterPrefixCollision service initialized: channels=%s notify_on_prefix_bytes=%s",
|
||||
"RepeaterPrefixCollision service initialized: channels=%s notify_on_prefix_bytes=%s "
|
||||
"notify_external_on_all_new_repeaters=%s silence_mesh_output=%s",
|
||||
self.channels,
|
||||
self.notify_on_prefix_bytes,
|
||||
self.notify_external_on_all_new_repeaters,
|
||||
self.silence_mesh_output,
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
@@ -184,6 +203,9 @@ class RepeaterPrefixCollisionService(BaseServicePlugin):
|
||||
|
||||
self._prune_old_dedupe_state()
|
||||
|
||||
if self.notify_external_on_all_new_repeaters:
|
||||
await self._maybe_send_discovery_external(public_key, name, location)
|
||||
|
||||
for nbytes in self.notify_on_prefix_bytes:
|
||||
await self._maybe_notify_for_prefix_bytes(
|
||||
public_key=public_key,
|
||||
@@ -260,6 +282,31 @@ class RepeaterPrefixCollisionService(BaseServicePlugin):
|
||||
stale_p = [k for k, ts in self._prefix_cooldown.items() if ts < cutoff]
|
||||
for prefix_key in stale_p:
|
||||
del self._prefix_cooldown[prefix_key]
|
||||
if self._discovery_notified:
|
||||
stale_d = [k for k, ts in self._discovery_notified.items() if ts < cutoff]
|
||||
for dk in stale_d:
|
||||
del self._discovery_notified[dk]
|
||||
|
||||
async def _maybe_send_discovery_external(self, public_key: str, name: str, location: str) -> None:
|
||||
"""Webhook/Telegram discovery line; deduped per public_key (same cooldown window)."""
|
||||
if not self.has_external_notification_targets():
|
||||
return
|
||||
if self._is_discovery_recently_notified(public_key):
|
||||
return
|
||||
text = self._format_discovery_message(public_key, name, location)
|
||||
await self.send_external_notifications(text, discord_username="New repeater")
|
||||
self._discovery_notified[public_key] = time.time()
|
||||
|
||||
def _format_discovery_message(self, public_key: str, name: str, location: str) -> str:
|
||||
pk_short = f"{public_key[:16]}…" if len(public_key) > 16 else public_key
|
||||
return f"New repeater heard: {name} near {location}. Key {pk_short}"
|
||||
|
||||
def _is_discovery_recently_notified(self, public_key: str) -> bool:
|
||||
ts = self._discovery_notified.get(public_key)
|
||||
if not ts:
|
||||
return False
|
||||
cooldown_s = max(0, int(self.cooldown_minutes_per_prefix)) * 60
|
||||
return (time.time() - ts) < cooldown_s if cooldown_s else False
|
||||
|
||||
async def _maybe_notify_for_prefix_bytes(
|
||||
self,
|
||||
@@ -294,7 +341,12 @@ class RepeaterPrefixCollisionService(BaseServicePlugin):
|
||||
prefix_bytes=prefix_bytes,
|
||||
)
|
||||
|
||||
await self._send_to_channels(text)
|
||||
if not self.notify_external_on_all_new_repeaters:
|
||||
await self.send_external_notifications(
|
||||
text, discord_username="Repeater prefix collision"
|
||||
)
|
||||
if not self.silence_mesh_output:
|
||||
await self._send_to_channels(text)
|
||||
|
||||
pk_short = f"{public_key[:16]}…" if len(public_key) > 16 else public_key
|
||||
self.logger.info(
|
||||
|
||||
Reference in new issue
Block a user