mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-23 06:12:19 +00:00
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:
1 parent
38b259156e
commit
f9b1cbddb8
6 files changed
+282
-39
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}>")
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user