Compare commits

...
Author SHA1 Message Date
pdxlocations 7273e80639 Add new configuration options for device UI and modules 2026-07-24 09:55:53 -07:00
pdxlocations 954bd6ee82 Bump version to 1.5.12 in pyproject.toml 2026-07-24 09:35:48 -07:00
pdxlocations 2cebd6b659 Merge pull request #283 from pdxlocations:bot-enable-config
Move bot responder enable to app config
2026-07-24 09:31:51 -07:00
pdxlocations 72d959a3c1 Move bot responder enable to app config 2026-07-24 09:31:34 -07:00
pdxlocations 73b31a5124 Merge pull request #282 from pdxlocations:ping-reply
Enhance bot response to include triggering packet ID for native replies
2026-07-24 09:20:43 -07:00
pdxlocations d403d2cea5 Enhance bot response to include triggering packet ID for native replies 2026-07-24 09:20:11 -07:00
pdxlocations 59a7b5b488 Fix reply sender extraction to handle missing sender gracefully 2026-07-24 09:13:24 -07:00
pdxlocations 5baef73c3c Bump version to 1.5.11 in pyproject.toml 2026-07-18 22:58:03 -07:00
pdxlocations 46a73fbc65 Fix reply prefix logic to prevent nesting existing reply markers and update tests accordingly 2026-07-18 22:57:32 -07:00
pdxlocations 0145d84f3b Bump version to 1.5.10 in pyproject.toml 2026-07-18 22:50:29 -07:00
pdxlocations ff656f20ad Implement local short name retrieval and update reply prefix logic for outgoing messages 2026-07-18 22:49:57 -07:00
pdxlocations eef321cac7 Bump version to 1.5.9 in pyproject.toml 2026-07-18 22:25:00 -07:00
pdxlocations cc33716222 Merge pull request #281 from pdxlocations/replies
Support replies
2026-07-18 22:23:33 -07:00
15 changed files with 180 additions and 80 deletions

No files matched your search

-1
View File
@@ -60,7 +60,6 @@ For smaller displays you may wish to enable `single_pane_mode`:
- `CTRL` + `t` or `F4` = With the Node List highlighted, send a traceroute to the selected node
- `F5` = Display a node's info
- `CTRL` + `f` = With the Node List highlighted, favorite the selected node
- `CTRL` + `b` = Enable/Disable Autoresponder Bot (ping / pong)
- `CTRL` + `g` = With the Node List highlighted, ignore the selected node
- `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user.
- `CTRL` + `d` = With the Note List highlghted, remove a node from your nodedb.
+66 -7
View File
@@ -81,7 +81,6 @@ help.traceroute, "Ctrl+T or F4 = Traceroute", ""
help.node_info, "F5 = Full node info", ""
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
help.favorite, "Ctrl+F = Favorite", ""
help.bot_responder, "Ctrl+B = Toggle Bot Responder", ""
help.ignore, "Ctrl+G = Ignore", ""
help.search, "Ctrl+/ or / = Search", ""
help.help, "Ctrl+K = Help", ""
@@ -94,11 +93,6 @@ confirm.remove_ignored, "Remove {name} from Ignored?", ""
confirm.region_unset, "Your region is UNSET. Set it now?", ""
dialog.resize_title, "Resize Terminal", ""
dialog.resize_body, "Please resize the terminal to at least {rows} rows.", ""
bot.status.enabled, "Enabled", ""
bot.status.disabled, "Disabled", ""
bot.dialog.title, "Bot Responder", ""
bot.dialog.body, "Bot responder is now {status}.", ""
bot.status.message, "Bot responder is now {status}.", ""
[User Settings]
user, "User"
@@ -132,6 +126,7 @@ COLOR_CONFIG_GREEN, "Theme colors (green)", ""
[app_settings.ping_bot]
title, "Ping Bot", ""
enabled, "Enabled", "Enable automatic Ping Bot responses."
catch_words, "Catch words", "Semicolon-separated bot trigger words."
response_word, "Response word", "Bot response word."
@@ -257,6 +252,8 @@ heading_bold, "Heading bold", "The heading can be hard to read when 'INVERTED' o
wake_on_tap_or_motion, "Wake on tap or motion", "This option enables the ability to wake the device screen when motion, such as a tap on the device, is detected via an attached accelerometer, or a capacitive touch button."
compass_orientation, "Compass orientation", "Whether to rotate the compass."
use_12h_clock, "Use 12 hour clock"
use_long_node_name, "Use long node name", "Show each node's long name on the device display when space permits."
enable_message_bubbles, "Enable message bubbles", "Display messages in chat-style bubbles on supported device displays."
[config.device_ui]
title, "Device UI"
@@ -275,6 +272,10 @@ node_filter, "Node Filter", ""
node_highlight, "Node Highlight", ""
calibration_data, "Calibration Data", ""
map_data, "Map Data", ""
compass_mode, "Compass mode", "Controls whether the compass is dynamic, fixed, or freezes its heading."
screen_rgb_color, "Screen RGB color", "Sets the display color on supported RGB screens."
is_clockface_analog, "Analog clock face", "Use an analog clock face on supported device displays."
gps_format, "GPS coordinate format", "Select the coordinate format used by the device UI."
[config.device_ui.node_filter]
title, "Node Filter"
@@ -300,6 +301,12 @@ home, "Home", ""
style, "Style", ""
follow_gps, "Follow GPS", ""
[config.device_ui.map_data.home]
title, "Home location", ""
zoom, "Zoom", "Map zoom level for the home location."
latitude, "Latitude", "Latitude of the home location."
longitude, "Longitude", "Longitude of the home location."
[config.lora]
title, "LoRa"
use_preset, "Use modem preset", "Presets are pre-defined modem settings (Bandwidth, Spread Factor, and Coding Rate) which influence both message speed and range. The vast majority of users use a preset."
@@ -319,6 +326,8 @@ override_frequency, "Override frequency in MHz", "Overrides frequency slot. May
pa_fan_disabled, "PA Fan Disabled", "If true, disable the build-in PA FAN using pin define in RF95_FAN_EN"
ignore_mqtt, "Ignore MQTT", "Ignores any messages it receives via LoRa that came via MQTT somewhere along the path towards the device."
config_ok_to_mqtt, "OK to MQTT", "Indicates that the user approves their packets to be uplinked to MQTT brokers."
fem_lna_mode, "FEM LNA mode", "Configures the front-end module low-noise amplifier mode on supported hardware."
serial_hal_only, "Serial HAL only", "Use only the serial hardware abstraction layer on supported hardware."
[config.bluetooth]
title, "Bluetooth"
@@ -335,6 +344,7 @@ serial_enabled, "Enable serial console", ""
debug_log_api_enabled, "Enable debug log", "Set this to true to continue outputting live debug logs over serial or Bluetooth when the API is active."
admin_channel_enabled, "Enable legacy admin channel", "If the node you need to administer or be administered by is running 2.4.x or earlier, you should set this to enabled. Requires a secondary channel named 'admin' be present on both nodes."
admin_key, "Admin keys", "The public key(s) authorized to send administrative messages to this node. Only messages signed by these keys will be accepted for administrative control. Up to 3."
packet_signature_policy, "Packet signature policy", "Controls how the node validates packet signatures."
[module.mqtt]
title, "MQTT"
@@ -396,6 +406,7 @@ title, "Range Test"
enabled, "Enabled", "Enables the module."
sender, "Sender interval", "How long to wait between sending sequential test packets in seconds. 0 is default which disables sending messages."
save, "Save CSV file", "If enabled, all received messages are saved to the device's flash memory in a file named rangetest.csv. Leave disabled when using the Android or Apple apps. Saves directly to the device's flash memory (without the need for a smartphone). [warning]Only available on ESP32-based devices.[/warning]"
clear_on_reboot, "Clear CSV on reboot", "Delete the RangeTest.csv file when the device reboots. [warning]Only available on ESP32-based devices.[/warning]"
[module.telemetry]
title, "Telemetry"
@@ -413,6 +424,7 @@ health_measurement_enabled, "Health telemetry interval", "This option is used to
health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network."
health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display."
device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemetry"
air_quality_screen_enabled, "Air quality screen enabled", "Show air-quality telemetry on the device display."
[module.canned_message]
title, "Canned Message"
@@ -473,5 +485,52 @@ use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. O
title, "Paxcounter"
enabled, "Enabled", "Enables the module."
paxcounter_update_interval, "Update interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected."
Wi-Fi_threshold, "Wi-Fi Threshold", "WiFi RSSI threshold. Defaults to -80"
wifi_threshold, "Wi-Fi threshold", "Wi-Fi RSSI threshold. Defaults to -80."
ble_threshold, "BLE Threshold", "BLE RSSI threshold. Defaults to -80"
[module.statusmessage]
title, "Status Message"
node_status, "Node status", ""
[module.traffic_management]
title, "Traffic Management"
position_min_interval_secs, "Position minimum interval", "Minimum seconds between position updates from the same node. Set to 0 to disable position-update suppression."
nodeinfo_direct_response_max_hops, "NodeInfo direct-response maximum hops", "Maximum hop distance at which cached NodeInfo requests receive a direct response. Set to 0 to disable direct responses."
rate_limit_window_secs, "Rate-limit window", "Per-node rate-limit window in seconds. Set to 0 to disable rate limiting."
rate_limit_max_packets, "Rate-limit maximum packets", "Maximum packets allowed per node during the rate-limit window. Set to 0 to disable rate limiting."
unknown_packet_threshold, "Unknown-packet threshold", "Maximum unknown or undecryptable packets per rate-limit window before packets from that source are dropped. Set to 0 to disable filtering."
[module.tak]
title, "TaK"
team, "Team", "Your team color."
role, "Role", "Your role in the team."
[module.mesh_beacon]
title, "Mesh Beacon", ""
flags, "Beacon options", "Bitwise combination of the listen, broadcast, and legacy-split options."
broadcast_send_as_node, "Broadcast as node", "Optional node ID to use as the sender of beacon packets. Leave unset to send as this node."
broadcast_message, "Broadcast message", "Message included in each beacon broadcast. Maximum 100 bytes."
broadcast_offer_channel, "Offered channel", "Optional channel name and key to advertise in the beacon offer."
broadcast_offer_region, "Offered region", "Optional LoRa region to advertise in the beacon offer."
broadcast_offer_preset, "Offered modem preset", "Optional modem preset to advertise in the beacon offer."
broadcast_on_channel, "Broadcast channel", "Optional channel settings to use when transmitting beacons. Uses the primary channel when unset."
broadcast_on_region, "Broadcast region", "Optional LoRa region to use when transmitting beacons."
broadcast_on_preset, "Broadcast modem preset", "Optional modem preset to use when transmitting beacons."
broadcast_interval_secs, "Broadcast interval", "How often to broadcast beacons in seconds. The minimum and default is 3600 seconds."
broadcast_targets, "Broadcast targets", "Optional list of channel, region, and modem-preset targets for beacon broadcasts."
[module.mesh_beacon.broadcast_offer_channel]
title, "Offered channel", ""
psk, "PSK", "Encryption key for the offered channel."
name, "Name", "Name of the offered channel."
[module.mesh_beacon.broadcast_on_channel]
title, "Broadcast channel", ""
psk, "PSK", "Encryption key for the broadcast channel."
name, "Name", "Name of the broadcast channel."
[module.mesh_beacon.broadcast_targets]
title, "Broadcast targets", ""
preset, "Modem preset", "Optional modem preset for this beacon target."
region, "Region", "LoRa region for this beacon target."
channel_index, "Channel index", "Index of the configured channel to use for this beacon target."
+1 -6
View File
@@ -78,16 +78,10 @@ help.traceroute, "Ctrl+T ou F4 = Traceroute", ""
help.node_info, "F5 = Informations complètes du nœud", ""
help.archive_chat, "Ctrl+D = Archiver la discussion / supprimer le nœud", ""
help.favorite, "Ctrl+F = Favori", ""
help.bot_responder, "Ctrl+B = Activer/désactiver le bot répondeur", ""
help.ignore, "Ctrl+G = Ignorer", ""
help.search, "Ctrl+/ ou / = Rechercher", ""
help.help, "Ctrl+K = Aide", ""
help.no_help, "Aucune aide disponible.", ""
bot.status.enabled, "Activé", ""
bot.status.disabled, "Désactivé", ""
bot.dialog.title, "Bot répondeur", ""
bot.dialog.body, "Le bot répondeur est maintenant {status}.", ""
bot.status.message, "Le bot répondeur est maintenant {status}.", ""
[User Settings]
user, "Utilisateur", ""
@@ -118,6 +112,7 @@ ping_bot, "Bot Ping", ""
[app_settings.ping_bot]
title, "Bot Ping", ""
enabled, "Activé", "Activer les réponses automatiques du bot Ping."
catch_words, "Mots déclencheurs", "Mots déclencheurs du bot séparés par des points-virgules."
response_word, "Mot de réponse", "Mot de réponse du bot."
+1 -6
View File
@@ -78,7 +78,6 @@ help.traceroute, "Ctrl+T или F4 = Traceroute", ""
help.node_info, "F5 = Полная информация об узле", ""
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
help.favorite, "Ctrl+F = Избранное", ""
help.bot_responder, "Ctrl+B = Вкл/выкл автоответчик", ""
help.ignore, "Ctrl+G = Игнорировать", ""
help.search, "Ctrl+/ или / = Поиск", ""
help.help, "Ctrl+K = Справка", ""
@@ -91,11 +90,6 @@ confirm.remove_ignored, "Убрать {name} из игнорируемых?", ""
confirm.region_unset, "Ваш регион НЕ ЗАДАН. Установить сейчас?", ""
dialog.resize_title, "Увеличьте окно", ""
dialog.resize_body, "Пожалуйста, увеличьте окно до {rows} строк.", ""
bot.status.enabled, "Включен", ""
bot.status.disabled, "Выключен", ""
bot.dialog.title, "Автоответчик", ""
bot.dialog.body, "Автоответчик теперь {status}.", ""
bot.status.message, "Автоответчик теперь {status}.", ""
[User Settings]
user, "Пользователь"
@@ -129,6 +123,7 @@ COLOR_CONFIG_GREEN, "Цвета темы (зеленая)", ""
[app_settings.ping_bot]
title, "Пинг-бот", ""
enabled, "Включен", "Включить автоматические ответы пинг-бота."
catch_words, "Слова-триггеры", "Слова для активации бота, разделенные точкой с запятой."
response_word, "Ответное слово", "Ответное слово бота."
+16 -7
View File
@@ -5,7 +5,7 @@ import time
from typing import Any, Dict
import contact.ui.default_config as config
from contact.utilities.singleton import app_state, interface_state, ui_state
from contact.utilities.singleton import app_state, interface_state
from contact.message_handlers.tx_handler import send_message
BOT_RESPONSE_DELAY_SECONDS = 2.3
@@ -24,9 +24,15 @@ def is_bot_message(message: str) -> bool:
"""Return True when the incoming message should trigger an automatic response."""
return message.strip().casefold() in _get_bot_catch_words()
def is_bot_enabled() -> bool:
"""Return whether automatic responses are enabled in app settings."""
value = getattr(config, "ping_bot_enabled", False)
return value if isinstance(value, bool) else str(value).strip().casefold() == "true"
def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool:
"""Send a basic response when bot mode is enabled."""
if not ui_state.bot_mode_enabled:
if not is_bot_enabled():
return False
if not is_bot_message(message):
@@ -40,7 +46,8 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool
return False
snr = packet.get('rxSnr', -128)
rssi = packet.get('rxRssi', -128)
replyIDset = (packet.get('decoded') or {}).get('replyId', False)
incoming_reply_id = (packet.get('decoded') or {}).get('replyId')
packet_id = packet.get("id")
hop_start = packet.get('hopStart', 0)
hop_limit = packet.get('hopLimit', 0)
transport_type = packet.get('transportMechanism', None)
@@ -53,8 +60,8 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool
details.append(f"RSSI: {rssi}")
if hops != 0:
details.append(f"Hops: {hops}")
if replyIDset:
details.append(f"Relay: {replyIDset}")
if incoming_reply_id:
details.append(f"Relay: {incoming_reply_id}")
transport_text = str(transport_type).upper() if transport_type is not None else ""
for transport_name in ("UDP", "MQTT"):
if transport_name in transport_text:
@@ -69,10 +76,12 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool
time.sleep(BOT_RESPONSE_DELAY_SECONDS)
with app_state.lock:
if not ui_state.bot_mode_enabled:
if not is_bot_enabled():
return
send_message(response_data_string,channel=send_channel)
# Use the triggering packet's ID so Meshtastic clients render this
# automatic response as a native reply when that ID is available.
send_message(response_data_string, channel=send_channel, reply_id=packet_id)
# Import locally to avoid circular import at module import time.
from contact.ui.contact_ui import request_ui_redraw
-30
View File
@@ -468,9 +468,6 @@ def main_ui(stdscr: curses.window) -> None:
elif char == chr(6): # Ctrl + F to toggle favorite
handle_ctrl_f(stdscr)
elif char == chr(2): # Ctrl + B to toggle bot responder
handle_ctrl_b(stdscr)
elif char == chr(7): # Ctrl + G to toggle ignored
handle_ctlr_g(stdscr)
@@ -1023,7 +1020,6 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
t("ui.help.node_info", default="F5 = Full node info"),
t("ui.help.archive_chat", default="Ctrl+D = Archive chat / remove node"),
t("ui.help.favorite", default="Ctrl+F = Favorite"),
t("ui.help.bot_responder", default="Ctrl+B = Toggle Bot Responder"),
t("ui.help.ignore", default="Ctrl+G = Ignore"),
t("ui.help.search", default="Ctrl+/ = Search"),
t("ui.help.help", default="Ctrl+K = Help"),
@@ -1035,32 +1031,6 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
handle_resize(stdscr, False)
def handle_ctrl_b(stdscr: curses.window) -> None:
"""Handle Ctrl + B key events to toggle automatic bot responses."""
ui_state.bot_mode_enabled = not ui_state.bot_mode_enabled
status = t("ui.bot.status.enabled", default="Enabled") if ui_state.bot_mode_enabled else t(
"ui.bot.status.disabled", default="Disabled"
)
curses.curs_set(0)
contact.ui.dialog.dialog(
t("ui.bot.dialog.title", default="Bot Responder"),
t("ui.bot.dialog.body", default="Bot responder is now {status}.", status=status),
)
if ui_state.channel_list:
channel_id = ui_state.channel_list[ui_state.selected_channel]
add_new_message(
channel_id,
f"{config.message_prefix} Info: ",
t("ui.bot.status.message", default="Bot responder is now {status}.", status=status.lower()),
)
draw_messages_window(True)
curses.curs_set(1)
handle_resize(stdscr, False)
def handle_ctrl_d() -> None:
if ui_state.current_window == 0:
if isinstance(ui_state.channel_list[ui_state.selected_channel], int):
+3 -1
View File
@@ -243,6 +243,7 @@ def initialize_config() -> Dict[str, object]:
"node_sort": "lastHeard",
"theme": "dark",
"ping_bot": {
"enabled": "False",
"catch_words": "ping; test",
"response_word": "Pong!",
},
@@ -280,7 +281,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
global node_list_16ths, channel_list_16ths, single_pane_mode
global theme, COLOR_CONFIG, language
global node_sort, notification_sound, ping_bot_catch_words, ping_bot_response_word
global node_sort, notification_sound, ping_bot_enabled, ping_bot_catch_words, ping_bot_response_word
channel_list_16ths = loaded_config["channel_list_16ths"]
node_list_16ths = loaded_config["node_list_16ths"]
@@ -300,6 +301,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
node_sort = loaded_config["node_sort"]
theme = loaded_config["theme"]
ping_bot = loaded_config.get("ping_bot", {})
ping_bot_enabled = ping_bot.get("enabled", "False")
ping_bot_catch_words = ping_bot.get("catch_words", "ping; test")
ping_bot_response_word = ping_bot.get("response_word", "Pong!")
if theme == "dark":
-2
View File
@@ -27,8 +27,6 @@ class ChatUIState:
current_window: int = 0
last_sent_time: float = 0.0
last_traceroute_time: float = 0.0
bot_mode_enabled: bool = False
selected_index: int = 0
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
show_save_option: bool = False
+3 -8
View File
@@ -115,6 +115,9 @@ def edit_color_pair(key: str, display_label: str, current_value: List[str]) -> L
def edit_value(key: str, display_label: str, current_value: str) -> str:
if key in ("notification_sound", "single_pane_mode", "enabled"):
return get_list_input(display_label, current_value, ["True", "False"])
w = get_effective_width()
height = 10
input_width = w - 16 # Allow space for "New Value: "
@@ -170,14 +173,6 @@ def edit_value(key: str, display_label: str, current_value: str) -> str:
sort_options = ["lastHeard", "name", "hops"]
return get_list_input(display_label, current_value, sort_options)
elif key == "notification_sound":
sound_options = ["True", "False"]
return get_list_input(display_label, current_value, sound_options)
elif key == "single_pane_mode":
sound_options = ["True", "False"]
return get_list_input(display_label, current_value, sound_options)
# Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, t("ui.label.new_value", default="New Value: "), get_color("settings_default"))
curses.curs_set(1)
+19 -4
View File
@@ -160,13 +160,28 @@ def get_time_ago(timestamp):
REPLY_EXCERPT_LENGTH = 5
def _local_short_name() -> str:
"""Return this node's short name without depending on the message database."""
nodes = getattr(interface_state.interface, "nodes", {}) or {}
for node in nodes.values():
if node.get("num") == interface_state.myNodeNum:
short_name = node.get("user", {}).get("shortName")
if short_name:
return str(short_name)
return ""
def build_reply_prefix(prefix: str, message: str) -> str:
"""Create Contact's local display marker for a native Meshtastic reply."""
sender_text = re.sub(r"^\[[^]]+\]\s*", "", prefix).strip()
sender_text = re.sub(r"^(?:>>|<<)\s*(?:\[[^]]+\]\s*)?", "", sender_text)
sender_match = re.search(r"(.+?)\s*:\s*$", sender_text)
sender = sender_match.group(1).strip() if sender_match else "me"
excerpt = " ".join(message.replace("\x00", "").split())[:REPLY_EXCERPT_LENGTH]
if sender_text.startswith(config.sent_message_prefix.strip()):
sender = _local_short_name()
else:
sender_text = re.sub(r"^(?:>>|<<)\s*(?:\[[^]]+\]\s*)?", "", sender_text)
sender_match = re.search(r"(.+?)\s*:\s*$", sender_text)
sender = sender_match.group(1).strip() if sender_match else ""
message_without_reply_marker = re.sub(r"^<Re:\s*[^>]*>\s*", "", message.replace("\x00", ""))
excerpt = " ".join(message_without_reply_marker.split())[:REPLY_EXCERPT_LENGTH]
return f"<Re: {sender}: {excerpt}> "
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.5.8"
version = "1.5.12"
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
+32
View File
@@ -5,6 +5,8 @@ import types
from unittest import mock
import contact.ui.default_config as config
from contact.utilities.singleton import interface_state
from tests.test_support import reset_singletons
class BotHandlerTests(unittest.TestCase):
@@ -16,6 +18,13 @@ class BotHandlerTests(unittest.TestCase):
)
cls.bot_handler = importlib.import_module("contact.message_handlers.bot_handler")
def setUp(self) -> None:
reset_singletons()
self.bot_handler.send_message.reset_mock()
def tearDown(self) -> None:
reset_singletons()
def test_is_bot_message_uses_configured_catch_words(self) -> None:
with mock.patch.object(config, "ping_bot_catch_words", "ping; test; pong"):
self.assertTrue(self.bot_handler.is_bot_message("PING"))
@@ -26,6 +35,29 @@ class BotHandlerTests(unittest.TestCase):
with mock.patch.object(config, "ping_bot_catch_words", " ; ; "):
self.assertTrue(self.bot_handler.is_bot_message("ping"))
def test_bot_response_replies_to_triggering_packet(self) -> None:
interface_state.myNodeNum = 111
packet = {"id": 900, "from": 222, "decoded": {}}
with mock.patch.object(config, "ping_bot_enabled", "True"):
with mock.patch.object(self.bot_handler.threading, "Thread") as thread:
self.assertTrue(self.bot_handler.bot_respond(packet, "ping", 0))
send_response = thread.call_args.kwargs["target"]
with mock.patch.object(self.bot_handler.time, "sleep"):
with mock.patch.dict(
sys.modules,
{"contact.ui.contact_ui": types.SimpleNamespace(request_ui_redraw=mock.Mock())},
):
send_response()
self.bot_handler.send_message.assert_called_once_with(
"Pong!", channel=0, reply_id=900
)
def test_bot_response_requires_enabled_app_setting(self) -> None:
with mock.patch.object(config, "ping_bot_enabled", "False"):
self.assertFalse(self.bot_handler.bot_respond({"from": 222}, "ping", 0))
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -1,4 +1,5 @@
import unittest
from types import SimpleNamespace
from unittest import mock
import contact.ui.default_config as config
@@ -110,6 +111,23 @@ class ContactUiTests(unittest.TestCase):
self.assertEqual(reply, "<Re: B1G1: This > ")
def test_build_reply_prefix_uses_me_for_an_outgoing_message(self) -> None:
contact_ui.interface_state.myNodeNum = 123
contact_ui.interface_state.interface = SimpleNamespace(
nodes={"!0000007b": {"num": 123, "user": {"shortName": "LRY"}}}
)
reply = contact_ui.build_reply_prefix("[06:27:25] >> Sent[☼]: ", "Thanks Lairy")
self.assertEqual(reply, "<Re: LRY: Thank> ")
def test_build_reply_prefix_does_not_nest_an_existing_reply_marker(self) -> None:
reply = contact_ui.build_reply_prefix(
"[06:27:25] >> xT1e: ",
"<Re: CATS: Woot!> Woot indeed",
)
self.assertEqual(reply, "<Re: xT1e: Woot > ")
def test_handle_ctrl_r_prefills_reply_for_message_at_cursor(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
+2 -7
View File
@@ -57,15 +57,10 @@ class I18nTests(unittest.TestCase):
self.assertEqual(i18n.t("missing", default="fallback"), "fallback")
self.assertEqual(parse_ini_file.call_count, 2)
def test_bot_ui_translation_keys_exist_in_all_locales(self) -> None:
def test_ping_bot_settings_translation_keys_exist_in_all_locales(self) -> None:
required_keys = {
"ui.help.bot_responder",
"ui.bot.status.enabled",
"ui.bot.status.disabled",
"ui.bot.dialog.title",
"ui.bot.dialog.body",
"ui.bot.status.message",
"app_settings.ping_bot",
"app_settings.ping_bot.enabled",
"app_settings.ping_bot.catch_words",
"app_settings.ping_bot.response_word",
}
+18
View File
@@ -0,0 +1,18 @@
import unittest
from unittest import mock
import contact.ui.default_config # Initialize config before the color helpers.
from contact.ui import user_config
class UserConfigTests(unittest.TestCase):
def test_ping_bot_enabled_uses_boolean_picker(self) -> None:
with mock.patch.object(user_config, "get_list_input", return_value="True") as picker:
result = user_config.edit_value("enabled", "Enabled", "False")
self.assertEqual(result, "True")
picker.assert_called_once_with("Enabled", "False", ["True", "False"])
if __name__ == "__main__":
unittest.main()