diff --git a/config.ini.example b/config.ini.example index dc466e1..9bdddab 100644 --- a/config.ini.example +++ b/config.ini.example @@ -211,7 +211,8 @@ admin_pubkeys = # Commands that require admin access (comma-separated) # These commands will only work for users in the admin_pubkeys list # reload: Reload configuration without restarting (radio settings cannot be changed) -admin_commands = repeater,webviewer,reload +# channelpause: DM-only; channelpause / channelresume — pause or resume bot responses on channels (not persisted) +admin_commands = repeater,webviewer,reload,channelpause [Plugin_Overrides] # Plugin Overrides - Use alternative plugin implementations diff --git a/config.ini.minimal-example b/config.ini.minimal-example index 680e237..52b819a 100644 --- a/config.ini.minimal-example +++ b/config.ini.minimal-example @@ -180,7 +180,7 @@ admin_pubkeys = # Commands that require admin access (comma-separated) # These commands will only work for users in the admin_pubkeys list # reload: Reload configuration without restarting (radio settings cannot be changed) -admin_commands = repeater,webviewer,reload +admin_commands = repeater,webviewer,reload,channelpause [Keywords] # Available placeholders (message-based): diff --git a/config.ini.quickstart b/config.ini.quickstart index b95544c..8a72b39 100644 --- a/config.ini.quickstart +++ b/config.ini.quickstart @@ -25,7 +25,7 @@ banned_users = [Admin_ACL] # 64-char hex public keys (comma-separated). Leave blank to disable admin commands. admin_pubkeys = -admin_commands = repeater,webviewer,reload +admin_commands = repeater,webviewer,reload,channelpause [Keywords] test = "ack @[{sender}]{phrase_part} | {connection_info} | Received at: {timestamp}" diff --git a/docs/config-validation.md b/docs/config-validation.md index 5f42c86..8cf96d0 100644 --- a/docs/config-validation.md +++ b/docs/config-validation.md @@ -46,7 +46,7 @@ The bot will not start without these sections. The validator reports them as **e If these are absent, the validator reports **info** (no error): -- **`[Admin_ACL]`** – Absent means admin commands (repeater, webviewer, reload) are disabled. +- **`[Admin_ACL]`** – Absent means admin commands (repeater, webviewer, reload, channelpause) are disabled. - **`[Banned_Users]`** – Absent means no users are banned. - **`[Localization]`** – Absent means defaults (e.g. `language=en`, `translation_path=translations/`) are used. diff --git a/docs/configuration.md b/docs/configuration.md index 6a09fa9..8e1fc6d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,3 +93,7 @@ Before starting the bot, you can validate section names and path writability. Se ## Reloading configuration Some configuration can be reloaded without restarting the bot using the **`reload`** command (admin only). Radio/connection settings are not changed by reload; restart the bot for those. + +## Pausing channel responses (remote) + +Admins can DM **`channelpause`** or **`channelresume`** (see `[Admin_ACL]` in `config.ini`) to stop or resume bot reactions on **public channels** only—greeter, keywords, and commands on channels are skipped; DMs still work. The setting is **in memory only** (back to responding on channels after restart). Scheduled channel posts from the scheduler are **not** blocked by this toggle. diff --git a/modules/commands/channelpause_command.py b/modules/commands/channelpause_command.py new file mode 100644 index 0000000..d65989a --- /dev/null +++ b/modules/commands/channelpause_command.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +Channel pause command +DM-only admin: pause or resume bot responses on public channels (in-memory only). +""" + +from .base_command import BaseCommand +from ..models import MeshMessage + + +class ChannelPauseCommand(BaseCommand): + """Pause or resume channel-triggered bot responses (greeter, keywords, commands).""" + + name = "channelpause" + keywords = ["channelpause", "channelresume"] + description = "Pause or resume bot responses on channels (DM only, admin only)" + requires_dm = True + cooldown_seconds = 2 + category = "admin" + + def __init__(self, bot): + super().__init__(bot) + + def can_execute(self, message: MeshMessage) -> bool: + if not self.requires_admin_access(): + return False + return super().can_execute(message) + + def requires_admin_access(self) -> bool: + return True + + def get_help_text(self) -> str: + return ( + "Controls whether the bot responds to public channel messages.\n" + "DMs always work (including this command).\n" + "Not saved across restarts.\n" + "Usage: channelpause — stop channel responses\n" + " channelresume — resume channel responses" + ) + + def _stripped_content_lower(self, message: MeshMessage) -> str: + content = message.content.strip() + if self._command_prefix: + if not content.startswith(self._command_prefix): + return "" + content = content[len(self._command_prefix) :].strip() + elif content.startswith("!"): + content = content[1:].strip() + content = self._strip_mentions(content) + return content.lower() + + async def execute(self, message: MeshMessage) -> bool: + text = self._stripped_content_lower(message) + resume_kw = self.keywords[1].lower() + pause_kw = self.keywords[0].lower() + + if text == resume_kw or text.startswith(resume_kw + " "): + self.bot.channel_responses_enabled = True + reply = "Channel responses: ON. The bot will respond on public channels again." + elif text == pause_kw or text.startswith(pause_kw + " "): + self.bot.channel_responses_enabled = False + reply = ( + "Channel responses: OFF. No greeter, keywords, or commands on channels; " + "DMs still work. Not persisted after restart." + ) + else: + reply = "Use channelpause or channelresume." + + await self.send_response(message, reply) + return True diff --git a/modules/commands/greeter_command.py b/modules/commands/greeter_command.py index bc1f1fe..0dffd28 100644 --- a/modules/commands/greeter_command.py +++ b/modules/commands/greeter_command.py @@ -1201,6 +1201,15 @@ class GreeterCommand(BaseCommand): self.logger.debug(f"Waiting {self.dead_air_delay_seconds} seconds before greeting {message.sender_id} on {message.channel}") await asyncio.sleep(self.dead_air_delay_seconds) + if not getattr(self.bot, "channel_responses_enabled", True): + self.logger.info( + f"Skipping delayed greeting for {message.sender_id} on {message.channel} " + "(channel responses paused)" + ) + if key in self.pending_greetings: + del self.pending_greetings[key] + return + # Check if greeting was cancelled (user was already greeted or human responded) if key not in self.pending_greetings: self.logger.debug(f"Greeting for {message.sender_id} on {message.channel} was cancelled") diff --git a/modules/config_validation.py b/modules/config_validation.py index 86256b0..6a88ec4 100644 --- a/modules/config_validation.py +++ b/modules/config_validation.py @@ -177,7 +177,7 @@ def validate_config(config_path: str) -> list[tuple[str, str]]: if ADMIN_ACL_SECTION not in sections_present: results.append(( SEVERITY_INFO, - f"Section [{ADMIN_ACL_SECTION}] absent; admin commands (repeater, webviewer, reload) disabled.", + f"Section [{ADMIN_ACL_SECTION}] absent; admin commands (repeater, webviewer, reload, channelpause) disabled.", )) if BANNED_USERS_SECTION not in sections_present: results.append(( diff --git a/modules/core.py b/modules/core.py index 4283c55..9a29361 100644 --- a/modules/core.py +++ b/modules/core.py @@ -81,6 +81,9 @@ class MeshCoreBot: self.connected = False self.connection_time = None # Track when connection was established to skip old cached messages + # Volatile: DM-only admin command (channelpause) toggles this; not persisted across restarts. + self.channel_responses_enabled = True + # Bot start time for uptime tracking self.start_time = time.time() diff --git a/modules/message_handler.py b/modules/message_handler.py index 6a7bf58..7766678 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -2628,7 +2628,7 @@ class MessageHandler: # Check greeter command for public channel messages (BEFORE general message filtering) # This allows greeter to work on its own configured channels even if not in monitor_channels - if 'greeter' in self.bot.command_manager.commands: + if self._channel_responses_allowed(message) and 'greeter' in self.bot.command_manager.commands: greeter_command = self.bot.command_manager.commands['greeter'] # First, check if this message should cancel a pending greeting (human greeting detection) if greeter_command: @@ -2779,6 +2779,11 @@ class MessageHandler: self.logger.debug(f"Ignoring message from banned user: {message.sender_id}") return False + # Channel-only pause (DM-only admin command); DMs still processed + if not message.is_dm and not getattr(self.bot, "channel_responses_enabled", True): + self.logger.debug("Ignoring non-DM message: channel responses paused") + return False + # Check if channel is monitored (with command override support) if not message.is_dm and message.channel: # Check if channel is in global monitor_channels @@ -2804,6 +2809,12 @@ class MessageHandler: return True + def _channel_responses_allowed(self, message: MeshMessage) -> bool: + """True if channel-driven bot responses are allowed for this message (DMs always True here).""" + if message.is_dm: + return True + return getattr(self.bot, "channel_responses_enabled", True) + async def handle_new_contact(self, event, metadata=None): """Handle NEW_CONTACT events for automatic contact management""" try: