diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d07896..a2fca6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ semantic versioning. `--embed-css` file stops generation with an error. See `docs/command-reference-website.md` for examples and the CSS class reference. +- Add the `contact` command (#293), which replies with the bot's own contact + card so a user can add the bot and DM it without waiting for an advert. Useful + for bots that do not advertise. Enabled by default; disable with + `[Contact_Command] enabled = false`. + ### Changed - `meshcore` now requires 2.3.14 or newer. Before 2.3.13, `send_msg_with_retry` diff --git a/config.ini.example b/config.ini.example index dcef21f..b243310 100644 --- a/config.ini.example +++ b/config.ini.example @@ -111,7 +111,7 @@ message_correlation_timeout = 10.0 enable_enhanced_correlation = true # Bot node ID (leave empty for auto-assignment) -node_id = +node_id = # Command prefix (optional). Single prefix (!), multiple decorative (!~.), # or comma-separated (!, ~, .). First is shown in help/docs. @@ -187,7 +187,7 @@ dm_flood_after = 2 # Timezone for bot operations # Use standard timezone names (e.g., America/New_York, Europe/London, UTC) # Leave empty to use system timezone -timezone = +timezone = # Bot location for geographic proximity calculations and astronomical data # Default latitude for bot location (decimal degrees) @@ -349,13 +349,13 @@ auto_detect_language = false # - Public keys MUST be exactly 64 hexadecimal characters (ed25519 format) # - Invalid formats will be rejected with error logs # - Empty or whitespace-only values disable admin access -# - Keys are case-insensitive (normalized to lowercase) +# - Keys are case-insensitive (normalized to lowercase) # # Format: comma-separated list of 64-character hex public keys (without spaces) # Example: f5d2b56d19b24412756933e917d4632e088cdd5daeadc9002feca73bf5d2b56d,1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef # # IMPORTANT: Leave blank to disable all admin commands. Set your actual admin pubkey(s) here. -admin_pubkeys = +admin_pubkeys = # Commands that require admin access (comma-separated) # These commands will only work for users in the admin_pubkeys list @@ -368,13 +368,13 @@ admin_commands = repeater,webviewer,reload,channelpause,neighbors # Format: command_name = alternative_file_name # The alternative_file_name should be the name of a Python file (without .py extension) # in the modules/commands/alternatives/ directory -# +# # Example: To use an alternative weather plugin for international users: # wx = wx_international -# +# # This will replace the default wx command with the plugin from # modules/commands/alternatives/wx_international.py -# +# # Note: The alternative plugin must have the same 'name' metadata as the command # it's replacing, or the override will use the alternative plugin's name instead. # @@ -433,7 +433,7 @@ companion_min_inactive_days = 30 # # To use a literal backslash + n, use \\n (double backslash + n) # Other escape sequences: \t (tab), \r (carriage return), \\ (literal backslash) -# +# # Available placeholders (mesh network info - same as Scheduled_Messages): # Total counts (ever heard): # {total_contacts} - Total number of contacts ever heard @@ -441,23 +441,23 @@ companion_min_inactive_days = 30 # {total_companions} - Total number of companion devices ever heard # {total_roomservers} - Total number of roomserver devices ever heard # {total_sensors} - Total number of sensor devices ever heard -# +# # Recent activity: # {recent_activity_24h} - Number of unique users active in last 24 hours -# +# # Active in last 30 days (last_heard): # {total_contacts_30d} - Total contacts active (last_heard) in last 30 days # {total_repeaters_30d} - Total repeaters active (last_heard) in last 30 days # {total_companions_30d} - Total companions active (last_heard) in last 30 days # {total_roomservers_30d} - Total roomservers active (last_heard) in last 30 days # {total_sensors_30d} - Total sensors active (last_heard) in last 30 days -# +# # New devices (first heard in last 7 days): # {new_companions_7d} - New companion devices first heard in last 7 days # {new_repeaters_7d} - New repeater devices first heard in last 7 days # {new_roomservers_7d} - New roomserver devices first heard in last 7 days # {new_sensors_7d} - New sensor devices first heard in last 7 days -# +# # Legacy placeholders (for backward compatibility): # {repeaters} - Same as {total_repeaters} # {companions} - Same as {total_companions} @@ -532,7 +532,7 @@ category.funfact = fun # # Newlines: use \n in the message for a line break (e.g. general:Line one\nLine two). # Literal backslash: use \\n for backslash+n; \\t for tab. -# +# # Command output placeholder: # # {cmd: [args]} runs a bot command and substitutes its reply text, so any @@ -564,34 +564,34 @@ category.funfact = fun # fires, the placeholder expands to nothing that round. # # Available placeholders for mesh network information: -# +# # Total counts (ever heard): # {total_contacts} - Total number of contacts ever heard # {total_repeaters} - Total number of repeater devices ever heard # {total_companions} - Total number of companion devices ever heard # {total_roomservers} - Total number of roomserver devices ever heard # {total_sensors} - Total number of sensor devices ever heard -# +# # Recent activity: # {recent_activity_24h} - Number of unique users active in last 24 hours -# +# # Active in last 30 days (last_heard): # {total_contacts_30d} - Total contacts active (last_heard) in last 30 days # {total_repeaters_30d} - Total repeaters active (last_heard) in last 30 days # {total_companions_30d} - Total companions active (last_heard) in last 30 days # {total_roomservers_30d} - Total roomservers active (last_heard) in last 30 days # {total_sensors_30d} - Total sensors active (last_heard) in last 30 days -# +# # New devices (first heard in last 7 days): # {new_companions_7d} - New companion devices first heard in last 7 days # {new_repeaters_7d} - New repeater devices first heard in last 7 days # {new_roomservers_7d} - New roomserver devices first heard in last 7 days # {new_sensors_7d} - New sensor devices first heard in last 7 days -# +# # Legacy placeholders (for backward compatibility): # {repeaters} - Same as {total_repeaters} # {companions} - Same as {total_companions} -# +# # Example with placeholders (cron): # 0 8 * * * = Public:Good morning! Network: {total_contacts} total ({total_repeaters} repeaters, {total_companions} companions). {new_repeaters_7d} new repeaters, {new_companions_7d} new companions in last 7d. {recent_activity_24h} active in 24h. # Example with 30-day active devices and new devices in 7d: @@ -659,33 +659,33 @@ short_url_website_service = gd short_url_website = https://v.gd # API key. Optional for gd (unused for public v.gd/is.gd, appended as ?key= for # alternate hosts). Required for shlink, where it is sent as an X-Api-Key header. -short_url_website_api_key = +short_url_website_api_key = # Weather API key (future feature) -weather_api_key = +weather_api_key = # Weather update interval in seconds (future feature) weather_update_interval = 3600 # Tide API key (future feature) -tide_api_key = +tide_api_key = # Tide update interval in seconds (future feature) tide_update_interval = 1800 # N2YO API key for satellite pass information # Get free key at: https://www.n2yo.com/login/ -n2yo_api_key = +n2yo_api_key = # AirNow API key for AQI data # Get free key at: https://docs.airnowapi.org/ -airnow_api_key = +airnow_api_key = # Forecast.Solar API key for solar forecast data # Get key at: https://forecast.solar/ (free tier works without key, paid tier for 3+ day forecasts) # Free tier: 2-day forecast, 1-hour resolution # Paid tier (14 EUR/year): 3-6 day forecast, 15-30 minute resolution -forecast_solar_api_key = +forecast_solar_api_key = # Optional external repeater-prefix API for the prefix command. # @@ -700,7 +700,7 @@ forecast_solar_api_key = # Fetched with a plain GET, must answer HTTP 200 with that JSON, 10s timeout. # "prefix" is upper-cased by the bot; "node_count" is an int; "node_names" a list. # Responses are cached for repeater_prefix_cache_hours below. -repeater_prefix_api_url = +repeater_prefix_api_url = # Repeater prefix cache duration in hours # How long to cache prefix data before refreshing from API @@ -1082,7 +1082,7 @@ path_selection_preset = balanced # Basic Settings # Geographic proximity calculation method -# simple: Use proximity to bot location +# simple: Use proximity to bot location # path: Use proximity to previous/next nodes in the path for more realistic routing (default) proximity_method = path @@ -1331,7 +1331,7 @@ enabled = false # Comma-separated list to restrict to specific channels (only greeter command works there) # Example: channels = general,welcome,newbies # If not specified, uses the channels from [Channels] monitor_channels setting -# channels = +# channels = # Greeting message template (default for all channels) # Available fields: {sender} - the user's name/ID @@ -1349,7 +1349,7 @@ greeting_message = Welcome to the mesh, @[{sender}]! # channel name. (So avoid text like ", wx: sunny" if wx is also a channel.) # Multi-part greetings are supported per channel using pipe (|) separator # Leave empty to use greeting_message for all channels -channel_greetings = +channel_greetings = # Per-channel greetings (tracking behavior) # false: Greet each user only once globally (default - user gets one greeting total) @@ -1434,7 +1434,7 @@ enabled = false # Format: comma-separated list of 64-character hex public keys (without spaces) # Example: f5d2b56d19b24412756933e917d4632e088cdd5daeadc9002feca73bf5d2b56d # Leave empty to only use Admin_ACL members -announcements_acl = +announcements_acl = # Default channel for announcements when no channel is specified # Announcements will be sent to this channel if no channel is provided @@ -1536,6 +1536,9 @@ enabled = true [Dice_Command] enabled = true # channels = +[Contact_Command] +enabled = true +# channels = [Cmd_Command] enabled = true # Optional: override `cmd` output with a link to your full docs page @@ -1782,7 +1785,7 @@ mesh_graph_cache_seconds = 30 # # Note: Only hashtag channels work here - custom channels with private keys # must be added to the radio itself. -decode_hashtag_channels = +decode_hashtag_channels = #################################################################################################### # # @@ -1798,7 +1801,7 @@ enabled = false # Output file for packet data (optional) # Leave empty to disable file output # Packets will be written as JSON lines -output_file = +output_file = # Verbose output (show JSON packet data in logs) # true: Show packet data in logs @@ -1930,7 +1933,7 @@ neighbors_self_scopes = owner_public_key = # Owner email address -owner_email = +owner_email = # Private key file path for auth token generation (fallback if device signing unavailable) # Optional - on-device signing is preferred @@ -1938,7 +1941,7 @@ owner_email = # Required only if device doesn't support on-device signing or auth_token_method = python # Note: If not provided and auth_token_method = python, the service will attempt to fetch # the private key from the device automatically -private_key_path = +private_key_path = # Auth token signing method # device: Try on-device signing first, fallback to Python signing (default, recommended) @@ -2034,8 +2037,8 @@ mqtt2_token_audience = mqtt-eu-v1.letsmesh.net mqtt2_topic_status = meshcore/{IATA}/{PUBLIC_KEY}/status mqtt2_topic_packets = meshcore/{IATA}/{PUBLIC_KEY}/packets mqtt2_websocket_path = /mqtt -mqtt2_client_id = -mqtt2_upload_packet_types = +mqtt2_client_id = +mqtt2_upload_packet_types = # Stats and status publishing # Enable stats in status messages @@ -2072,7 +2075,7 @@ api_url = https://map.meshcore.dev/api/v1/uploader/node # If not provided, the service will attempt to fetch the private key from the device # Supports 64-byte orlp format (128 hex chars) or 32-byte seed (64 hex chars) # Required only if device doesn't support private key export -private_key_path = +private_key_path = # Minimum time between re-uploads of same node (seconds) # Prevents uploading the same node too frequently to avoid API spam @@ -2104,10 +2107,10 @@ weather_alarm = 6:00 # Bot position for weather forecasts and alerts # Latitude in decimal degrees -my_position_lat = +my_position_lat = # Longitude in decimal degrees -my_position_lon = +my_position_lon = # Channel for daily weather forecasts # Weather forecasts will be sent to this channel diff --git a/docs/command-reference.md b/docs/command-reference.md index 2779238..0b313b7 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -97,6 +97,19 @@ cmd --- +### `contact` + +Share the bot's own contact card so you can add it and send DMs without waiting for an advert. + +**Usage:** +``` +contact +``` + +**Response:** A clickable contact card containing the bot's public key and device name. + +--- + ### `version` Show the bot's current software version. diff --git a/modules/commands/contact_command.py b/modules/commands/contact_command.py new file mode 100644 index 0000000..1238386 --- /dev/null +++ b/modules/commands/contact_command.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +Contact command for the MeshCore Bot +Adds the bot contact info to the current channel +""" + +import re +from typing import Any, Optional + +from modules.commands.base_command import BaseCommand +from modules.models import MeshMessage + +PUBLIC_KEY_RE = re.compile(r'^[0-9a-fA-F]{64}$') + + +class ContactCommand(BaseCommand): + """Handles contact command""" + + # Plugin metadata + name = "contact" + keywords = ['contact'] + description = "Display the bot's contact information" + category = "basic" + + # Documentation + short_description = "Display the bot's contact information" + usage = "contact" + examples = [ + "contact" + ] + + def __init__(self, bot): + """Initialize the contact command. + + Args: + bot: The bot instance. + """ + super().__init__(bot) + self.enabled = self.get_config_value('Contact_Command', 'enabled', fallback=True, value_type='bool') + + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: + """Check if this command can be executed with the given message. + + Args: + message: The message triggering the command. + skip_channel_check: If True, skip the channel check. + + Returns: + bool: True if command is enabled and checks pass, False otherwise. + """ + if not self.enabled: + return False + return super().can_execute(message, skip_channel_check=skip_channel_check) + + def get_help_text(self) -> str: + """Get help text for the contact command. + + Returns: + str: Help text string. + """ + return self.translate('commands.contact.help') + + def matches_keyword(self, message: MeshMessage) -> bool: + """Match ``contact`` (or a configured alias) on its own, with no arguments. + + Args: + message: The received message. + + Returns: + bool: True if the message is a contact command, False otherwise. + """ + def _matches(content_lower: str) -> bool: + return any(content_lower == keyword.lower() for keyword in self.keywords) + + return self._cleaned_content_matches(message, _matches) + + def _self_info_value(self, key: str) -> Optional[str]: + """Read a field from the radio's self_info, which may be a dict or an object. + + Args: + key: The self_info field name. + + Returns: + str: The field value, or None if unavailable. + """ + meshcore: Any = getattr(self.bot, 'meshcore', None) + self_info = getattr(meshcore, 'self_info', None) if meshcore else None + if not self_info: + return None + if isinstance(self_info, dict): + value = self_info.get(key) + else: + value = getattr(self_info, key, None) + return str(value).strip() if value else None + + async def execute(self, message: MeshMessage) -> bool: + """Execute the contact command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ + public_key = self._self_info_value('public_key') + name = self._self_info_value('name') or self._self_info_value('adv_name') + + if not public_key or not PUBLIC_KEY_RE.match(public_key): + self.logger.warning("Contact command: no usable public key in self_info") + return await self.send_response(message, self.translate('commands.contact.unavailable')) + + if not name: + self.logger.warning("Contact command: no device name in self_info") + return await self.send_response(message, self.translate('commands.contact.unavailable')) + + return await self.send_response(message, f"<{public_key.lower()}:1:{name}>") diff --git a/tests/test_contact_command.py b/tests/test_contact_command.py new file mode 100644 index 0000000..d21a9ed --- /dev/null +++ b/tests/test_contact_command.py @@ -0,0 +1,102 @@ +"""Tests for modules.commands.contact_command.""" + +import asyncio +import configparser +from unittest.mock import AsyncMock, MagicMock, Mock + +from modules.commands.contact_command import ContactCommand +from tests.conftest import mock_message + +PUBKEY = "f5d2b56d19b24412756933e917d4632e088cdd5daeadc9002feca73bf5d2b56d" + + +def _make_bot(self_info=None): + bot = MagicMock() + bot.logger = Mock() + config = configparser.ConfigParser() + config.add_section("Bot") + config.set("Bot", "bot_name", "TestBot") + config.set("Bot", "respond_to_mentions", "also") + config.add_section("Channels") + config.set("Channels", "monitor_channels", "general") + config.set("Channels", "respond_to_dms", "true") + config.add_section("Keywords") + config.add_section("Contact_Command") + config.set("Contact_Command", "enabled", "true") + bot.config = config + bot.translator = MagicMock() + bot.translator.translate = Mock(side_effect=lambda key, **kw: key) + bot.command_manager = MagicMock() + bot.command_manager.monitor_channels = ["general"] + bot.meshcore = MagicMock() + bot.meshcore.self_info = self_info + return bot + + +def _make_command(self_info=None): + cmd = ContactCommand(_make_bot(self_info)) + cmd.send_response = AsyncMock(return_value=True) + return cmd + + +class TestMatching: + def test_matches_bare_keyword(self): + cmd = _make_command() + assert cmd.matches_keyword(mock_message("contact")) is True + + def test_does_not_match_with_arguments(self): + cmd = _make_command() + assert cmd.matches_keyword(mock_message("contact me")) is False + + def test_matches_configured_alias(self): + bot = _make_bot() + bot.config.set("Contact_Command", "aliases", "card") + cmd = ContactCommand(bot) + assert cmd.matches_keyword(mock_message("card")) is True + + def test_non_match_leaves_message_content_untouched(self): + # Regression guard for #267: the keyword scan must not rewrite + # overheard traffic when this command does not match. + cmd = _make_command() + message = mock_message("@[TestBot] wx 98101") + assert cmd.matches_keyword(message) is False + assert message.content == "@[TestBot] wx 98101" + + +class TestExecute: + def test_sends_contact_card(self): + cmd = _make_command({"public_key": PUBKEY, "name": "TestBot"}) + assert asyncio.run(cmd.execute(mock_message("contact"))) is True + cmd.send_response.assert_awaited_once() + assert cmd.send_response.await_args[0][1] == f"<{PUBKEY}:1:TestBot>" + + def test_reads_self_info_object(self): + self_info = MagicMock(spec=["public_key", "name"]) + self_info.public_key = PUBKEY.upper() + self_info.name = "TestBot" + cmd = _make_command(self_info) + assert asyncio.run(cmd.execute(mock_message("contact"))) is True + assert cmd.send_response.await_args[0][1] == f"<{PUBKEY}:1:TestBot>" + + def test_missing_self_info_reports_unavailable(self): + cmd = _make_command(None) + assert asyncio.run(cmd.execute(mock_message("contact"))) is True + assert cmd.send_response.await_args[0][1] == "commands.contact.unavailable" + + def test_malformed_public_key_reports_unavailable(self): + cmd = _make_command({"public_key": "nothex", "name": "TestBot"}) + asyncio.run(cmd.execute(mock_message("contact"))) + assert cmd.send_response.await_args[0][1] == "commands.contact.unavailable" + + def test_missing_name_reports_unavailable(self): + cmd = _make_command({"public_key": PUBKEY}) + asyncio.run(cmd.execute(mock_message("contact"))) + assert cmd.send_response.await_args[0][1] == "commands.contact.unavailable" + + +class TestEnabledFlag: + def test_disabled_blocks_execution(self): + bot = _make_bot({"public_key": PUBKEY, "name": "TestBot"}) + bot.config.set("Contact_Command", "enabled", "false") + cmd = ContactCommand(bot) + assert cmd.can_execute(mock_message("contact")) is False diff --git a/translations/en.json b/translations/en.json index 10cecf2..c8437d2 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1347,6 +1347,10 @@ }, "cmd": { "description": "List available commands" + }, + "contact": { + "help": "Display the bot's contact information", + "unavailable": "Contact info unavailable: the radio has not reported its identity yet." } }, "elapsed": {