feat(command): Add contact command (#293)

* feat(command): Add contact command

The contact command allows the user to ask the bot to provide its
public key as a clickable contact. This allows the user to then DM the
bot outside of a channel.

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>

* fix(contact): harden matching and self_info handling, add tests and docs

Review follow-ups on the contact command:

- Match through _cleaned_content_matches so a non-matching message keeps its
  original content. The raw cleanup_message_for_matching call rewrote
  message.content in place during the keyword scan, which is the #267
  regression every other command already avoids.
- Match against self.keywords so a configured alias works.
- Read self_info defensively (dict or object, may be absent) and validate the
  public key instead of sending a literal "<None:1:None>" over the air.
- Drop the hardcoded '!' prefix strip in execute; matching already normalizes
  the content, and prefixes are configurable.
- Forward skip_channel_check to the base can_execute.
- Remove copy-paste docstring leftovers from the roll command.
- Move [Contact_Command] into the alphabetical run in config.ini.example and
  fix the "chanels" typo.
- Add tests, a docs/command-reference.md entry, and flesh out the changelog.

---------

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>
Co-authored-by: agessaman <adam@gessaman.com>
This commit is contained in:
Gerard Hickeyandagessaman authored and GitHub committed 2026-09-19 20:19:38 -07:00
1 parent 38b259156e
commit f9b1cbddb8
6 files changed
+282 -39

No files matched your search

+5
View File
@@ -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`
+42 -39
View File
@@ -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:<command> [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
+13
View File
@@ -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.
+116
View File
@@ -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}>")
+102
View File
@@ -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
+4
View File
@@ -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": {