Compare commits

..
25 Commits
Author SHA1 Message Date
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
pdxlocations 7efc1a640d Enhance messaging features with reply support and message selection preservation
- Updated `send_message` to include reply ID and context for native replies.
- Modified `onAckNak` to correctly update the database with the message context.
- Implemented reply prefix formatting in `build_reply_prefix` for better message clarity.
- Added functionality to preserve message selection when new messages are received.
- Enhanced database handling to store packet IDs and reply IDs for messages.
- Updated UI state to manage reply context and message highlighting effectively.
- Improved tests to cover new reply features and message selection behavior.
2026-07-18 22:21:55 -07:00
pdxlocations b54e467ef0 Merge pull request #280 from pdxlocations:message-loading
Slowness with busy channels
2026-07-18 21:34:40 -07:00
pdxlocations 8c01f8fc17 Reduce MESSAGE_PAGE_SIZE to improve message loading efficiency and update tests to verify separator consistency. 2026-07-18 21:34:07 -07:00
pdxlocations c6164af582 Refactor message loading logic to replace channel messages and avoid duplicates 2026-07-18 21:28:57 -07:00
pdxlocations 52f42591d2 Slowness with busy channels
Fixes #279
2026-07-18 21:24:32 -07:00
pdxlocations 69cb568d2c bump version to 1.5.8 in pyproject.toml 2026-05-07 13:50:36 -07:00
pdxlocations 68b08787de Enhance error handling in contact UI and improve metrics display logic 2026-05-07 13:50:12 -07:00
pdxlocations 80ede60153 bump version to 1.5.7 in pyproject.toml 2026-03-28 22:42:34 -07:00
pdxlocations 046ede23a2 Merge pull request #265 from stason200711/main
Allow overriding DATA_DIR via environment variable and fix file descriptor leaks
2026-03-28 22:41:47 -07:00
pdxlocations 0ad39fd6a0 Add Ping Bot Config to Settings 2026-03-28 22:35:34 -07:00
pdxlocations 6721874937 add bot strings to i18n and fix dilogue width 2026-03-28 22:22:41 -07:00
pdxlocations 8ee21b1973 Merge pull request #266 from SpudGunMan:bot
auto respond bot
2026-03-28 22:10:26 -07:00
Kelly 0760d6d0ca auto respond bot
Added an optional auto-responder bot that listens for configurable trigger words (from localisation) and sends a response with available link metadata (like SNR/RSSI/hops/transport) The feature is toggleable from the UI shortcut ctl+B
2026-03-28 19:57:22 -07:00
stason200711 cd0b114f13 Update db_handler.py 2026-03-28 09:52:37 +03:00
stason200711 2d4b407caa Update default_config.py
Allow overriding DATA_DIR via environment variable
2026-03-26 09:55:00 +03:00
stason200711 80b9311959 Update demo_data.py
Fix file descriptor leaks
2026-03-26 09:52:08 +03:00
stason200711 da5f5bff39 Update db_handler.py
Fix file descriptor leaks
2026-03-25 23:20:36 +03:00
23 changed files with 1376 additions and 207 deletions

No files matched your search

+1
View File
@@ -60,6 +60,7 @@ 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.
+15
View File
@@ -67,6 +67,8 @@ dialog.traceroute_not_sent_body, "Please wait {seconds} seconds before sending a
dialog.traceroute_sent_title, "Traceroute Sent To: {name}", ""
dialog.traceroute_sent_body, "Results will appear in messages window.", ""
dialog.help_title, "Help - Shortcut Keys", ""
dialog.reply_unavailable_title, "Reply unavailable", ""
dialog.reply_unavailable_body, "This message has no packet ID, so Contact cannot send a native Meshtastic reply.", ""
help.scroll, "Up/Down = Scroll", ""
help.switch_window, "Left/Right = Switch window", ""
help.jump_windows, "F1/F2/F3 = Jump to Channel/Messages/Nodes", ""
@@ -74,10 +76,12 @@ help.enter, "ENTER = Send / Select", ""
help.settings, "` or F12 = Settings", ""
help.quit, "ESC = Quit", ""
help.packet_log, "Ctrl+P = Toggle Packet Log", ""
help.reply, "Ctrl+R = Reply to message at cursor", ""
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", ""
@@ -90,6 +94,11 @@ 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"
@@ -116,10 +125,16 @@ nak_str, "NAK", ""
ack_unknown_str, "ACK (unknown)", ""
node_sort, "Node sort", ""
theme, "Theme", ""
ping_bot, "Ping Bot", ""
COLOR_CONFIG_DARK, "Theme colors (dark)", ""
COLOR_CONFIG_LIGHT, "Theme colors (light)", ""
COLOR_CONFIG_GREEN, "Theme colors (green)", ""
[app_settings.ping_bot]
title, "Ping Bot", ""
catch_words, "Catch words", "Semicolon-separated bot trigger words."
response_word, "Response word", "Bot response word."
[app_settings.color_config]
default, "Default", ""
background, "Background", ""
+12
View File
@@ -78,10 +78,16 @@ 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", ""
@@ -108,6 +114,12 @@ nak_str, "NAK", ""
ack_unknown_str, "ACK (inconnu)", ""
node_sort, "Tri des nœuds", ""
theme, "Thème", ""
ping_bot, "Bot Ping", ""
[app_settings.ping_bot]
title, "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."
[config.device]
title, "Appareil", ""
+12
View File
@@ -78,6 +78,7 @@ 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 = Справка", ""
@@ -90,6 +91,11 @@ 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, "Пользователь"
@@ -116,10 +122,16 @@ nak_str, "NAK", ""
ack_unknown_str, "ACK (неизвестный)", ""
node_sort, "Сортировка нод", ""
theme, "Тема", ""
ping_bot, "Пинг-бот", ""
COLOR_CONFIG_DARK, "Цвета темы (темная)", ""
COLOR_CONFIG_LIGHT, "Цвета темы (светлая)", ""
COLOR_CONFIG_GREEN, "Цвета темы (зеленая)", ""
[app_settings.ping_bot]
title, "Пинг-бот", ""
catch_words, "Слова-триггеры", "Слова для активации бота, разделенные точкой с запятой."
response_word, "Ответное слово", "Ответное слово бота."
[app_settings.color_config]
default, "По умолчанию", ""
background, "Фон", ""
+90
View File
@@ -0,0 +1,90 @@
# A basic auto-responder bot that replies to specific messages when bot mode is enabled.
import logging
import threading
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.message_handlers.tx_handler import send_message
BOT_RESPONSE_DELAY_SECONDS = 2.3
def _get_bot_catch_words() -> set[str]:
"""Return normalized bot trigger words from app settings."""
raw_words = getattr(config, "ping_bot_catch_words", "ping; test")
words = {
word.strip().casefold()
for word in raw_words.replace(";", ",").split(",")
if word.strip()
}
return words or {"ping"}
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 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:
return False
if not is_bot_message(message):
""" Only respond to specific messages. """
return False
from_node = packet.get("from")
if from_node is None:
return False
if from_node == interface_state.myNodeNum:
return False
snr = packet.get('rxSnr', -128)
rssi = packet.get('rxRssi', -128)
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)
hops = hop_start - hop_limit
details = []
if snr != -128:
details.append(f"SNR: {snr}")
if rssi != -128:
details.append(f"RSSI: {rssi}")
if hops != 0:
details.append(f"Hops: {hops}")
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:
details.append(f"Via: {transport_name}")
response_data_string = getattr(config, "ping_bot_response_word", "Pong!")
if details:
response_data_string += f" {', '.join(details)}"
def send_response_delayed() -> None:
try:
time.sleep(BOT_RESPONSE_DELAY_SECONDS)
with app_state.lock:
if not ui_state.bot_mode_enabled:
return
# 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
request_ui_redraw(channels=True, messages=True, scroll_messages_to_bottom=True)
logging.info("Bot response sent to %s on channel index %s", from_node, send_channel)
except Exception:
logging.exception("Bot response send failed for destination %s", from_node)
threading.Thread(target=send_response_delayed, name="bot-response", daemon=True).start()
return True
+25 -3
View File
@@ -46,6 +46,7 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
from contact.utilities.utils import (
refresh_node_list,
add_new_message,
get_reply_context,
)
from contact.ui.contact_ui import (
add_notification,
@@ -60,6 +61,7 @@ from contact.utilities.db_handler import (
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state
from contact.message_handlers.bot_handler import bot_respond
def play_sound():
@@ -168,6 +170,8 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
channel_number = ui_state.channel_list.index(packet["from"])
bot_respond(packet, message_string, channel_number)
channel_id = ui_state.channel_list[channel_number]
if channel_id != ui_state.channel_list[ui_state.selected_channel]:
@@ -180,14 +184,32 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
message_from_id = packet["from"]
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string)
# replyId is a field of Meshtastic's decrypted Data payload.
reply_id = packet["decoded"].get("replyId")
reply_context = get_reply_context(reply_id) if reply_id is not None else ""
add_new_message(
channel_id,
f"{config.message_prefix} [{hops}] {message_from_string} ",
f"{reply_context}{message_string}",
packet_id=packet.get("id"),
)
if refresh_channels:
request_ui_redraw(channels=True)
if refresh_messages:
request_ui_redraw(messages=True, scroll_messages_to_bottom=True)
request_ui_redraw(
messages=True,
scroll_messages_to_bottom=True,
preserve_message_selection=(ui_state.current_window == 1),
)
save_message_to_db(channel_id, message_from_id, message_string)
save_message_to_db(
channel_id,
message_from_id,
message_string,
packet_id=packet.get("id"),
reply_id=reply_id,
)
except KeyError as e:
logging.error(f"Error processing packet: {e}")
+27 -12
View File
@@ -56,7 +56,7 @@ def onAckNak(packet: Dict[str, Any]) -> None:
message,
)
update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type)
update_ack_nak(acknak["channel"], acknak["timestamp"], acknak["dbMessage"], ack_type)
channel_number = ui_state.channel_list.index(acknak["channel"])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
@@ -164,7 +164,13 @@ def on_response_traceroute(packet: Dict[str, Any]) -> None:
save_message_to_db(channel_id, packet["from"], msg_str)
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
def send_message(
message: str,
destination: int = BROADCAST_NUM,
channel: int = 0,
reply_id: int | None = None,
reply_context: str = "",
) -> None:
"""
Sends a chat message using the selected channel.
"""
@@ -177,23 +183,32 @@ def send_message(message: str, destination: int = BROADCAST_NUM, channel: int =
elif isinstance(channel_id, str):
send_on_channel = channel
sent_message_data = interface_state.interface.sendText(
text=message,
destinationId=destination,
wantAck=True,
wantResponse=False,
onResponse=onAckNak,
channelIndex=send_on_channel,
send_kwargs = {
"text": message,
"destinationId": destination,
"wantAck": True,
"wantResponse": False,
"onResponse": onAckNak,
"channelIndex": send_on_channel,
}
if reply_id is not None:
send_kwargs["replyId"] = reply_id
sent_message_data = interface_state.interface.sendText(**send_kwargs)
add_new_message(
channel_id,
config.sent_message_prefix + config.ack_unknown_str + ": ",
f"{reply_context}{message}",
packet_id=sent_message_data.id,
)
add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message)
timestamp = save_message_to_db(channel_id, myid, message)
timestamp = save_message_to_db(channel_id, myid, message, packet_id=sent_message_data.id, reply_id=reply_id)
ack_naks[sent_message_data.id] = {
"channel": channel_id,
"messageIndex": len(ui_state.all_messages[channel_id]) - 1,
"timestamp": timestamp,
"dbMessage": message,
}
+330 -102
View File
@@ -2,14 +2,22 @@ import curses
import logging
import time
import traceback
from numbers import Real
from typing import Union
from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list
from contact.utilities.utils import (
get_channels,
get_readable_duration,
get_time_ago,
refresh_node_list,
add_new_message,
build_reply_prefix,
)
from contact.settings import settings_menu
from contact.message_handlers.tx_handler import send_message, send_traceroute
from contact.utilities.utils import parse_protobuf
from contact.ui.colors import get_color
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived, load_older_messages
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.utilities.emoji_utils import normalize_message_text
@@ -40,6 +48,7 @@ def request_ui_redraw(
packetlog: bool = False,
full: bool = False,
scroll_messages_to_bottom: bool = False,
preserve_message_selection: bool = False,
) -> None:
ui_state.redraw_channels = ui_state.redraw_channels or channels
ui_state.redraw_messages = ui_state.redraw_messages or messages
@@ -47,6 +56,7 @@ def request_ui_redraw(
ui_state.redraw_packetlog = ui_state.redraw_packetlog or packetlog
ui_state.redraw_full_ui = ui_state.redraw_full_ui or full
ui_state.scroll_messages_to_bottom = ui_state.scroll_messages_to_bottom or scroll_messages_to_bottom
ui_state.preserve_message_selection = ui_state.preserve_message_selection or preserve_message_selection
def process_pending_ui_updates(stdscr: curses.window) -> None:
@@ -57,6 +67,7 @@ def process_pending_ui_updates(stdscr: curses.window) -> None:
ui_state.redraw_nodes = False
ui_state.redraw_packetlog = False
ui_state.scroll_messages_to_bottom = False
ui_state.preserve_message_selection = False
handle_resize(stdscr, False)
return
@@ -70,9 +81,14 @@ def process_pending_ui_updates(stdscr: curses.window) -> None:
if ui_state.redraw_messages:
scroll_to_bottom = ui_state.scroll_messages_to_bottom
preserve_selection = ui_state.preserve_message_selection
ui_state.redraw_messages = False
ui_state.scroll_messages_to_bottom = False
draw_messages_window(scroll_to_bottom)
ui_state.preserve_message_selection = False
if preserve_selection:
draw_messages_window(scroll_to_bottom, preserve_selection=True)
else:
draw_messages_window(scroll_to_bottom)
if ui_state.redraw_packetlog:
ui_state.redraw_packetlog = False
@@ -369,7 +385,8 @@ def main_ui(stdscr: curses.window) -> None:
while True:
with app_state.lock:
process_pending_ui_updates(stdscr)
draw_text_field(entry_win, f"Message: {(input_text or '')[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
entry_display = f"{ui_state.reply_context}{input_text or ''}"
draw_text_field(entry_win, f"Message: {entry_display[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
# Get user input from entry window
try:
@@ -425,6 +442,12 @@ def main_ui(stdscr: curses.window) -> None:
elif char == chr(16): # Ctrl + P for Packet Log
handle_ctrl_p()
elif char == chr(18): # Ctrl + R for Reply
cancelling_reply = bool(ui_state.reply_context)
input_text = handle_ctrl_r(input_text)
if cancelling_reply:
entry_win.erase()
elif char == curses.KEY_RESIZE:
input_text = ""
queued_char = drain_resize_events(entry_win)
@@ -445,6 +468,9 @@ 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)
@@ -484,7 +510,8 @@ def handle_home() -> None:
if ui_state.current_window == 0:
select_channel(0)
elif ui_state.current_window == 1:
ui_state.selected_message = 0
set_message_selection(0)
refresh_message_highlight()
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(0)
@@ -497,8 +524,8 @@ def handle_end() -> None:
if ui_state.current_window == 0:
select_channel(len(ui_state.channel_list) - 1)
elif ui_state.current_window == 1:
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = max(msg_line_count - get_msg_window_lines(messages_win, packetlog_win), 0)
set_message_selection(messages_pad.getmaxyx()[0] - 1)
refresh_message_highlight()
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(len(ui_state.node_list) - 1)
@@ -510,9 +537,8 @@ def handle_pageup() -> None:
if ui_state.current_window == 0:
select_channel(ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2))
elif ui_state.current_window == 1:
ui_state.selected_message = max(
ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0
)
set_message_selection(ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win))
refresh_message_highlight()
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2))
@@ -524,11 +550,8 @@ def handle_pagedown() -> None:
if ui_state.current_window == 0:
select_channel(ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2))
elif ui_state.current_window == 1:
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = min(
ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win),
msg_line_count - get_msg_window_lines(messages_win, packetlog_win),
)
set_message_selection(ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win))
refresh_message_highlight()
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2))
@@ -551,6 +574,9 @@ def handle_leftright(char: int) -> None:
refresh_main_window(ui_state.current_window, selected=True)
draw_window_arrows(ui_state.current_window)
if ui_state.current_window == 1:
draw_messages_window(True)
refresh_message_highlight()
def handle_function_keys(char: int) -> None:
@@ -581,6 +607,9 @@ def handle_function_keys(char: int) -> None:
refresh_main_window(ui_state.current_window, selected=True)
draw_window_arrows(ui_state.current_window)
if ui_state.current_window == 1:
draw_messages_window(True)
refresh_message_highlight()
def handle_enter(input_text: str) -> str:
@@ -608,6 +637,15 @@ def handle_enter(input_text: str) -> str:
return input_text
elif len(input_text) > 0:
if ui_state.reply_context and ui_state.reply_id is None:
contact.ui.dialog.dialog(
t("ui.dialog.reply_unavailable_title", default="Reply unavailable"),
t(
"ui.dialog.reply_unavailable_body",
default="This message has no packet ID, so Contact cannot send a native Meshtastic reply.",
),
)
return input_text
# TODO: This is a hack to prevent sending messages too quickly. Let's get errors from the node.
now = time.monotonic()
if now - ui_state.last_sent_time < 2.5:
@@ -617,7 +655,15 @@ def handle_enter(input_text: str) -> str:
)
return input_text
# Enter key pressed, send user input as message
send_message(input_text, channel=ui_state.selected_channel)
send_message(
input_text,
channel=ui_state.selected_channel,
reply_id=ui_state.reply_id,
reply_context=ui_state.reply_context,
)
ui_state.reply_id = None
ui_state.reply_context = ""
ui_state.reply_id_unavailable = False
draw_messages_window(True)
ui_state.last_sent_time = now
entry_win.erase()
@@ -656,24 +702,27 @@ def handle_f5_key(stdscr: curses.window) -> None:
if has_coords:
message_parts.append(f"https://maps.google.com/?q={pos['latitude']:.4f},{pos['longitude']:.4f}")
if any(key in node for key in ["snr", "hopsAway", "lastHeard"]):
if any(node.get(key) is not None for key in ["snr", "hopsAway", "lastHeard"]):
message_parts.append("")
message_parts.append("**🌐 Network Metrics:**")
if "snr" in node:
if node.get("snr") is not None:
snr = node["snr"]
snr_status = (
"🟢 Excellent"
if snr > 10
else (
"🟡 Good"
if snr > 3
else "🟠 Fair" if snr > -10 else "🔴 Poor" if snr > -20 else "💀 Very Poor"
if isinstance(snr, Real):
snr_status = (
"🟢 Excellent"
if snr > 10
else (
"🟡 Good"
if snr > 3
else "🟠 Fair" if snr > -10 else "🔴 Poor" if snr > -20 else "💀 Very Poor"
)
)
)
message_parts.append(f"• SNR: {snr}dB {snr_status}")
message_parts.append(f"• SNR: {snr}dB {snr_status}")
else:
message_parts.append(f"• SNR: {snr}dB")
if "hopsAway" in node:
if node.get("hopsAway") is not None:
hops = node["hopsAway"]
hop_emoji = "📡" if hops == 0 else "🔄" if hops == 1 else "⏩"
message_parts.append(f"• Hops away: {hop_emoji} {hops}")
@@ -686,24 +735,32 @@ def handle_f5_key(stdscr: curses.window) -> None:
message_parts.append("")
message_parts.append("**📊 Device Metrics:**")
if "batteryLevel" in metrics:
if metrics.get("batteryLevel") is not None:
battery = metrics["batteryLevel"]
battery_emoji = "🟢" if battery > 50 else "🟡" if battery > 20 else "🔴"
battery_emoji = "🔴"
if isinstance(battery, Real):
battery_emoji = "🟢" if battery > 50 else "🟡" if battery > 20 else "🔴"
voltage_info = f" ({metrics['voltage']}v)" if "voltage" in metrics else ""
message_parts.append(f"• Battery: {battery_emoji} {battery}%{voltage_info}")
if "uptimeSeconds" in metrics:
if metrics.get("uptimeSeconds") is not None:
message_parts.append(f"• Uptime: ⏱️ {get_readable_duration(metrics['uptimeSeconds'])}")
if "channelUtilization" in metrics:
if metrics.get("channelUtilization") is not None:
util = metrics["channelUtilization"]
util_emoji = "🔴" if util > 80 else "🟡" if util > 50 else "🟢"
message_parts.append(f"• Channel utilization: {util_emoji} {util:.2f}%")
if isinstance(util, Real):
util_emoji = "🔴" if util > 80 else "🟡" if util > 50 else "🟢"
message_parts.append(f"• Channel utilization: {util_emoji} {util:.2f}%")
else:
message_parts.append(f"• Channel utilization: {util}%")
if "airUtilTx" in metrics:
if metrics.get("airUtilTx") is not None:
air_util = metrics["airUtilTx"]
air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢"
message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%")
if isinstance(air_util, Real):
air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢"
message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%")
else:
message_parts.append(f"• Air utilization TX: {air_util}%")
title = t(
"ui.dialog.node_details_title",
@@ -789,7 +846,10 @@ def handle_f5_key(stdscr: curses.window) -> None:
curses.doupdate()
dialog_win.timeout(200)
char = dialog_win.getch()
try:
char = dialog_win.getch()
except curses.error:
continue
if menu_state.need_redraw:
menu_state.need_redraw = False
@@ -818,7 +878,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
elif char == curses.KEY_RESIZE:
continue
except KeyError:
except (IndexError, KeyError):
return
finally:
if dialog_win is not None:
@@ -902,6 +962,49 @@ def handle_ctrl_p() -> None:
draw_messages_window(True)
def get_message_at_display_line(channel, display_line: int):
"""Return the message containing a rendered line in a channel, if any."""
line = 0
for prefix, message in ui_state.all_messages.get(channel, []):
wrapped_lines = wrap_text(
normalize_message_text(f"{prefix}{message}"),
messages_win.getmaxyx()[1] - 2,
)
if line <= display_line < line + len(wrapped_lines):
return None if prefix.startswith("--") else (prefix, message)
line += len(wrapped_lines)
return None
def handle_ctrl_r(input_text: str) -> str:
"""Toggle a reply to the message at the current Messages-pane position."""
if ui_state.reply_context:
ui_state.reply_id = None
ui_state.reply_context = ""
ui_state.reply_id_unavailable = False
return ""
if ui_state.current_window != 1 or not ui_state.channel_list:
return input_text
channel = ui_state.channel_list[ui_state.selected_channel]
selected = get_message_at_display_line(channel, ui_state.selected_message)
if selected is None:
return input_text
message_index = next(
index
for index, entry in enumerate(ui_state.all_messages[channel])
if entry == selected
)
packet_ids = ui_state.message_packet_ids.get(channel, [])
prefix, message = selected
ui_state.reply_context = build_reply_prefix(prefix, message)
ui_state.reply_id = packet_ids[message_index] if message_index < len(packet_ids) else None
ui_state.reply_id_unavailable = ui_state.reply_id is None
return input_text
# --- Ctrl+K handler for Help ---
def handle_ctrl_k(stdscr: curses.window) -> None:
"""Handle Ctrl + K to show a help window with shortcut keys."""
@@ -915,10 +1018,12 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
t("ui.help.settings", default="` or F12 = Settings"),
t("ui.help.quit", default="ESC = Quit"),
t("ui.help.packet_log", default="Ctrl+P = Toggle Packet Log"),
t("ui.help.reply", default="Ctrl+R = Reply to message at cursor"),
t("ui.help.traceroute", default="Ctrl+T or F4 = Traceroute"),
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"),
@@ -930,6 +1035,32 @@ 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):
@@ -1107,7 +1238,7 @@ def draw_channel_list() -> None:
channel_win.refresh()
def draw_messages_window(scroll_to_bottom: bool = False) -> None:
def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: bool = False) -> None:
"""Update the messages window based on the selected channel and scroll position."""
if ui_state.current_window != 1 and ui_state.single_pane_mode:
@@ -1117,18 +1248,15 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
channel = ui_state.channel_list[ui_state.selected_channel]
msg_line_count = 0
message_ranges = []
if channel in ui_state.all_messages:
messages = ui_state.all_messages[channel]
msg_line_count = 0
row = 0
rendered_lines = []
for prefix, message in messages:
start_line = len(rendered_lines)
full_message = normalize_message_text(f"{prefix}{message}")
wrapped_lines = wrap_text(full_message, messages_win.getmaxyx()[1] - 2)
msg_line_count += len(wrapped_lines)
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
for line in wrapped_lines:
if prefix.startswith("--"):
color = get_color("timestamps")
@@ -1137,18 +1265,31 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
else:
color = get_color("rx_messages")
messages_pad.addstr(row, 1, line, color)
row += 1
rendered_lines.append((line, color))
if not prefix.startswith("--"):
message_ranges.append((start_line, len(rendered_lines), color))
msg_line_count = len(rendered_lines)
messages_pad.resize(max(1, msg_line_count), messages_win.getmaxyx()[1])
for row, (line, color) in enumerate(rendered_lines):
messages_pad.addstr(row, 1, line, color)
ui_state.message_line_ranges[channel] = message_ranges
paint_frame(messages_win, selected=(ui_state.current_window == 1))
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
if scroll_to_bottom:
ui_state.selected_message = max(msg_line_count - visible_lines, 0)
ui_state.start_index[1] = max(msg_line_count - visible_lines, 0)
if preserve_selection:
set_message_selection(ui_state.selected_message)
ui_state.start_index[1] = max(0, msg_line_count - visible_lines)
else:
set_message_selection(msg_line_count - 1)
else:
ui_state.selected_message = max(min(ui_state.selected_message, msg_line_count - visible_lines), 0)
set_message_selection(ui_state.selected_message)
refresh_message_highlight()
messages_win.refresh()
refresh_pad(1)
@@ -1159,6 +1300,39 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
menu_state.need_redraw = True
def refresh_message_highlight() -> None:
"""Apply a reverse-video highlight to the message at the current scroll position."""
previous_range = ui_state.highlighted_message_range
width = max(0, messages_win.getmaxyx()[1] - 2)
if not ui_state.channel_list or ui_state.current_window != 1:
if previous_range:
start, end, color = previous_range
for row in range(start, end):
messages_pad.chgat(row, 1, width, color)
ui_state.highlighted_message_range = ()
return
channel = ui_state.channel_list[ui_state.selected_channel]
ranges = ui_state.message_line_ranges.get(channel, [])
selected_range = next(
(line_range for line_range in ranges if line_range[0] <= ui_state.selected_message < line_range[1]),
None,
)
if previous_range and previous_range != selected_range:
start, end, color = previous_range
for row in range(start, end):
messages_pad.chgat(row, 1, width, color)
if selected_range:
start, end, color = selected_range
for row in range(start, end):
messages_pad.chgat(row, 1, width, color | curses.A_REVERSE)
ui_state.highlighted_message_range = selected_range or ()
def draw_node_list() -> None:
"""Update the nodes list window and pad based on the current state."""
global nodes_pad
@@ -1238,31 +1412,78 @@ def scroll_channels(direction: int) -> None:
def scroll_messages(direction: int) -> None:
"""Scroll through the messages in the current channel by a given direction."""
ui_state.selected_message += direction
if direction < 0 and ui_state.selected_message == 0 and ui_state.channel_list:
channel = ui_state.channel_list[ui_state.selected_channel]
previous_height = messages_pad.getmaxyx()[0]
if load_older_messages(channel):
draw_messages_window()
added_height = max(0, messages_pad.getmaxyx()[0] - previous_height)
ui_state.selected_message = added_height
ui_state.start_index[1] = added_height
refresh_pad(1)
return
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = max(
0, min(ui_state.selected_message, msg_line_count - get_msg_window_lines(messages_win, packetlog_win))
)
max_index = msg_line_count - 1
visible_height = get_msg_window_lines(messages_win, packetlog_win)
if ui_state.selected_message < ui_state.start_index[ui_state.current_window]: # Moving above the visible area
ui_state.start_index[ui_state.current_window] = ui_state.selected_message
elif ui_state.selected_message >= ui_state.start_index[ui_state.current_window]: # Moving below the visible area
ui_state.start_index[ui_state.current_window] = ui_state.selected_message
# Ensure start_index is within bounds
ui_state.start_index[ui_state.current_window] = max(
0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1)
)
move_message_selection(direction)
refresh_message_highlight()
messages_win.refresh()
refresh_pad(1)
draw_window_arrows(ui_state.current_window)
def set_message_selection(line: int) -> None:
"""Select a rendered message line and keep it visible without limiting selection to the viewport."""
msg_line_count = messages_pad.getmaxyx()[0]
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
ui_state.selected_message = max(0, min(line, max(msg_line_count - 1, 0)))
max_start = max(msg_line_count - visible_lines, 0)
start = ui_state.start_index[1]
if ui_state.selected_message < start:
start = ui_state.selected_message
elif ui_state.selected_message >= start + visible_lines:
start = ui_state.selected_message - visible_lines + 1
ui_state.start_index[1] = max(0, min(start, max_start))
def move_message_selection(direction: int) -> None:
"""Move between messages, keeping wrapped messages as one selectable item."""
if not ui_state.channel_list:
return
channel = ui_state.channel_list[ui_state.selected_channel]
ranges = ui_state.message_line_ranges.get(channel, [])
if not ranges:
set_message_selection(ui_state.selected_message + direction)
return
current_index = next(
(index for index, line_range in enumerate(ranges) if line_range[0] <= ui_state.selected_message < line_range[1]),
None,
)
if current_index is None:
current_index = 0 if direction > 0 else len(ranges) - 1
else:
current_index = max(0, min(current_index + direction, len(ranges) - 1))
start, end, _color = ranges[current_index]
set_message_selection(end - 1 if current_index == len(ranges) - 1 else start)
def select_last_message() -> None:
"""Select and reveal the newest message in the active channel."""
if not ui_state.channel_list:
return
channel = ui_state.channel_list[ui_state.selected_channel]
ranges = ui_state.message_line_ranges.get(channel, [])
if ranges:
_start, end, _color = ranges[-1]
set_message_selection(end - 1)
else:
set_message_selection(0)
def select_node(idx: int) -> None:
"""Select a node by index and update the UI state accordingly."""
old_selected_node = ui_state.selected_node
@@ -1355,41 +1576,48 @@ def search(win: int) -> None:
search_text = ""
entry_win.erase()
entry_win.timeout(-1)
while True:
draw_centered_text_field(entry_win, f"Search: {search_text}", 0, get_color("input"))
char = entry_win.get_wch()
if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)):
break
elif char == "\t":
start_idx = ui_state.selected_node + 1 if win == 2 else ui_state.selected_channel + 1
elif char in (curses.KEY_BACKSPACE, chr(127)):
if search_text:
search_text = search_text[:-1]
y, x = entry_win.getyx()
entry_win.move(y, x - 1)
entry_win.addch(" ") #
entry_win.move(y, x - 1)
entry_win.erase()
entry_win.refresh()
elif isinstance(char, str):
search_text += char
search_text_caseless = search_text.casefold()
l = ui_state.node_list if win == 2 else ui_state.channel_list
for i, n in enumerate(l[start_idx:] + l[:start_idx]):
if (
isinstance(n, int)
and search_text_caseless in get_name_from_database(n, "long").casefold()
or isinstance(n, int)
and search_text_caseless in get_name_from_database(n, "short").casefold()
or search_text_caseless in str(n).casefold()
):
select_func((i + start_idx) % len(l))
try:
while True:
draw_centered_text_field(entry_win, f"Search: {search_text}", 0, get_color("input"))
try:
char = entry_win.get_wch()
except curses.error:
break
if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)):
break
elif char == "\t":
start_idx = ui_state.selected_node + 1 if win == 2 else ui_state.selected_channel + 1
elif char in (curses.KEY_BACKSPACE, chr(127)):
if search_text:
search_text = search_text[:-1]
y, x = entry_win.getyx()
entry_win.move(y, x - 1)
entry_win.addch(" ") #
entry_win.move(y, x - 1)
entry_win.erase()
entry_win.refresh()
elif isinstance(char, str):
search_text += char
search_text_caseless = search_text.casefold()
l = ui_state.node_list if win == 2 else ui_state.channel_list
for i, n in enumerate(l[start_idx:] + l[:start_idx]):
if (
isinstance(n, int)
and search_text_caseless in get_name_from_database(n, "long").casefold()
or isinstance(n, int)
and search_text_caseless in get_name_from_database(n, "short").casefold()
or search_text_caseless in str(n).casefold()
):
select_func((i + start_idx) % len(l))
break
finally:
entry_win.timeout(200)
entry_win.erase()
+15 -4
View File
@@ -56,9 +56,13 @@ def _get_config_root(preferred_dir: str, fallback_name: str = ".contact_client")
return fallback_dir
# Pick the root now.
config_root = _get_config_root(parent_dir)
# Allow overriding config root via environment variable
config_root = os.getenv("CONTACT_CONFIG_ROOT")
if config_root:
if not _is_writable_dir(config_root):
raise RuntimeError(f"CONTACT_CONFIG_ROOT={config_root} is not writable")
else:
config_root = _get_config_root(parent_dir)
# Paths (derived from the chosen root)
json_file_path = os.path.join(config_root, "config.json")
@@ -238,6 +242,10 @@ def initialize_config() -> Dict[str, object]:
"ack_unknown_str": "[…]",
"node_sort": "lastHeard",
"theme": "dark",
"ping_bot": {
"catch_words": "ping; test",
"response_word": "Pong!",
},
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
"COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT,
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN,
@@ -272,7 +280,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
global node_sort, notification_sound, ping_bot_catch_words, ping_bot_response_word
channel_list_16ths = loaded_config["channel_list_16ths"]
node_list_16ths = loaded_config["node_list_16ths"]
@@ -291,6 +299,9 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
ack_unknown_str = loaded_config["ack_unknown_str"]
node_sort = loaded_config["node_sort"]
theme = loaded_config["theme"]
ping_bot = loaded_config.get("ping_bot", {})
ping_bot_catch_words = ping_bot.get("catch_words", "ping; test")
ping_bot_response_word = ping_bot.get("response_word", "Pong!")
if theme == "dark":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
elif theme == "light":
+6 -7
View File
@@ -2,7 +2,7 @@ import curses
from contact.utilities.i18n import t_text
from contact.ui.colors import get_color
from contact.ui.nav_utils import draw_main_arrows
from contact.ui.nav_utils import draw_main_arrows, slice_to_width, text_width
from contact.utilities.singleton import menu_state, ui_state
@@ -19,10 +19,10 @@ def dialog(title: str, message: str) -> None:
# Parse message into lines and calculate dimensions
message_lines = message.splitlines() or [""]
max_line_length = max(len(l) for l in message_lines)
max_line_length = max(text_width(l) for l in message_lines)
# Desired size
dialog_width = max(len(title) + 4, max_line_length + 4)
dialog_width = max(text_width(title) + 4, max_line_length + 4)
desired_height = len(message_lines) + 4
# Clamp dialog size to the screen (leave a 1-cell margin if possible)
@@ -61,7 +61,7 @@ def dialog(title: str, message: str) -> None:
# Title
try:
win.addstr(0, 2, title[: max(0, dialog_width - 4)], get_color("settings_default"))
win.addstr(0, 2, slice_to_width(title, max(0, dialog_width - 4)), get_color("settings_default"))
except curses.error:
pass
@@ -80,9 +80,8 @@ def dialog(title: str, message: str) -> None:
if idx >= len(message_lines):
break
line = message_lines[idx]
# Hard-trim lines that don't fit
trimmed = line[: max(0, dialog_width - 6)]
msg_x = max(0, ((dialog_width - 2) - len(trimmed)) // 2)
trimmed = slice_to_width(line, max(0, dialog_width - 4))
msg_x = max(0, ((dialog_width - 2) - text_width(trimmed)) // 2)
try:
msg_win.addstr(1 + i, msg_x, trimmed, get_color("settings_default"))
except curses.error:
+10
View File
@@ -27,6 +27,7 @@ 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])
@@ -39,6 +40,15 @@ class ChatUIState:
redraw_packetlog: bool = False
redraw_full_ui: bool = False
scroll_messages_to_bottom: bool = False
preserve_message_selection: bool = False
oldest_message_rowid: Dict[Union[str, int], int] = field(default_factory=dict)
has_older_messages: Dict[Union[str, int], bool] = field(default_factory=dict)
message_line_ranges: Dict[Union[str, int], List[tuple]] = field(default_factory=dict)
highlighted_message_range: tuple = field(default_factory=tuple)
message_packet_ids: Dict[Union[str, int], List[Any]] = field(default_factory=dict)
reply_id: Any = None
reply_context: str = ""
reply_id_unavailable: bool = False
@dataclass
+4 -1
View File
@@ -309,7 +309,10 @@ def display_menu() -> tuple[Any, Any, List[str]]:
else:
display_key = key
display_key = f"{display_key}"[: w // 2 - 2]
display_value = f"{value}"[: w // 2 - 8]
if isinstance(value, dict) or (isinstance(value, list) and len(value) != 2):
display_value = ">"
else:
display_value = f"{value}"[: w // 2 - 8]
color = get_color("settings_default", reverse=(idx == menu_state.selected_index))
menu_pad.addstr(idx, 0, f"{display_key:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
+163 -69
View File
@@ -4,13 +4,16 @@ import logging
from datetime import datetime
from typing import Optional, Union, Dict
from contact.utilities.utils import decimal_to_hex
from contact.utilities.utils import build_reply_prefix, decimal_to_hex
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
MESSAGE_PAGE_SIZE = 100
def get_table_name(channel: str) -> str:
# Construct the table name
table_name = f"{str(interface_state.myNodeNum)}_{channel}_messages"
@@ -18,7 +21,21 @@ def get_table_name(channel: str) -> str:
return quoted_table_name
def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optional[int]:
def _ensure_message_columns(db_cursor, quoted_table_name: str) -> None:
"""Add fields introduced after the original message-history schema."""
table_columns = {row[1] for row in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")}
for column, definition in (("ack_type", "TEXT"), ("packet_id", "INTEGER"), ("reply_id", "INTEGER")):
if column not in table_columns:
db_cursor.execute(f"ALTER TABLE {quoted_table_name} ADD COLUMN {column} {definition}")
def save_message_to_db(
channel: str,
user_id: str,
message_text: str,
packet_id: Optional[int] = None,
reply_id: Optional[int] = None,
) -> Optional[int]:
"""Save messages to the database, ensuring the table exists."""
try:
quoted_table_name = get_table_name(channel)
@@ -27,20 +44,25 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT
ack_type TEXT,
packet_id INTEGER,
reply_id INTEGER
"""
ensure_table_exists(quoted_table_name, schema)
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
_ensure_message_columns(db_cursor, quoted_table_name)
timestamp = int(time.time())
# Insert the message
insert_query = f"""
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type)
VALUES (?, ?, ?, ?)
INSERT INTO {quoted_table_name}
(user_id, message_text, timestamp, ack_type, packet_id, reply_id)
VALUES (?, ?, ?, ?, ?, ?)
"""
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None))
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None, packet_id, reply_id))
db_connection.commit()
return timestamp
@@ -53,7 +75,8 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None:
try:
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
update_query = f"""
UPDATE {get_table_name(channel)}
@@ -73,35 +96,100 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
logging.error(f"Unexpected error in update_ack_nak: {e}")
def load_messages_from_db() -> None:
def _format_db_messages(db_messages, node_names):
"""Format database rows and packet IDs, adding one timestamp separator per hour."""
hourly_messages = {}
known_messages = {}
for _rowid, user_id, message, timestamp, ack_type, packet_id, reply_id in db_messages:
if user_id is None or message is None or timestamp is None:
logging.warning(f"Skipping row with NULL required field(s): {(user_id, message, timestamp, ack_type)}")
continue
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
ack_str = config.ack_unknown_str
if ack_type == "Implicit":
ack_str = config.ack_implicit_str
elif ack_type == "Ack":
ack_str = config.ack_str
elif ack_type == "Nak":
ack_str = config.nak_str
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
sanitized_message = message.replace("\x00", "")
if user_id == str(interface_state.myNodeNum):
formatted_message = (f"{ts_str} {config.sent_message_prefix}{ack_str}: ", sanitized_message)
else:
try:
fallback_name = decimal_to_hex(int(user_id))
except (TypeError, ValueError):
fallback_name = str(user_id)
formatted_message = (
f"{ts_str} {config.message_prefix} {node_names.get(str(user_id), fallback_name)}: ",
sanitized_message,
)
if reply_id is not None and reply_id in known_messages:
referenced_prefix, referenced_message = known_messages[reply_id]
formatted_message = (formatted_message[0], build_reply_prefix(referenced_prefix, referenced_message) + sanitized_message)
hourly_messages.setdefault(hour, []).append((formatted_message, packet_id))
if packet_id is not None:
known_messages[packet_id] = formatted_message
formatted = []
packet_ids = []
for hour, messages in sorted(hourly_messages.items()):
formatted.append((f"-- {hour} --", ""))
packet_ids.append(None)
for formatted_message, packet_id in messages:
formatted.append(formatted_message)
packet_ids.append(packet_id)
return formatted, packet_ids
def _load_node_names(db_cursor):
table_name = f'"{interface_state.myNodeNum}_nodedb"'
try:
return {str(row[0]): row[1] for row in db_cursor.execute(f"SELECT user_id, short_name FROM {table_name}")}
except sqlite3.Error:
return {}
def load_messages_from_db(page_size: int = MESSAGE_PAGE_SIZE) -> None:
"""Load messages from the database for all channels and update ui_state.all_messages and ui_state.channel_list."""
try:
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
db_cursor.execute(query, (f"{str(interface_state.myNodeNum)}_%_messages",))
tables = [row[0] for row in db_cursor.fetchall()]
node_names = _load_node_names(db_cursor)
# Iterate through each table and fetch its messages
for table_name in tables:
quoted_table_name = (
f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces
)
table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")]
if "ack_type" not in table_columns:
update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT"
db_cursor.execute(update_table_query)
_ensure_message_columns(db_cursor, quoted_table_name)
query = f"SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}"
query = f"""
SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id
FROM {quoted_table_name}
ORDER BY rowid DESC LIMIT ?
"""
try:
# Fetch all messages from the table
db_cursor.execute(query)
db_messages = [(row[0], row[1], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples
db_cursor.execute(query, (page_size + 1,))
rows = db_cursor.fetchall()
has_older = len(rows) > page_size
db_messages = list(reversed(rows[:page_size]))
# Extract the channel name from the table name
channel = table_name.split("_")[1]
prefix = f"{interface_state.myNodeNum}_"
channel = table_name[len(prefix) : -len("_messages")]
# Convert the channel to an integer if it's numeric, otherwise keep it as a string (nodenum vs channel name)
channel = int(channel) if channel.isdigit() else channel
@@ -110,53 +198,13 @@ def load_messages_from_db() -> None:
if channel not in ui_state.channel_list and not is_chat_archived(channel):
ui_state.channel_list.append(channel)
# Ensure the channel exists in ui_state.all_messages
if channel not in ui_state.all_messages:
ui_state.all_messages[channel] = []
# Add messages to ui_state.all_messages grouped by hourly timestamp
hourly_messages = {}
for row in db_messages:
user_id, message, timestamp, ack_type = row
# Only ack_type is allowed to be None
if user_id is None or message is None or timestamp is None:
logging.warning(f"Skipping row with NULL required field(s): {row}")
continue
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
if hour not in hourly_messages:
hourly_messages[hour] = []
ack_str = config.ack_unknown_str
if ack_type == "Implicit":
ack_str = config.ack_implicit_str
elif ack_type == "Ack":
ack_str = config.ack_str
elif ack_type == "Nak":
ack_str = config.nak_str
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
if user_id == str(interface_state.myNodeNum):
sanitized_message = message.replace("\x00", "")
formatted_message = (
f"{ts_str} {config.sent_message_prefix}{ack_str}: ",
sanitized_message,
)
else:
sanitized_message = message.replace("\x00", "")
formatted_message = (
f"{ts_str} {config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
sanitized_message,
)
hourly_messages[hour].append(formatted_message)
# Flatten the hourly messages into ui_state.all_messages[channel]
for hour, messages in sorted(hourly_messages.items()):
ui_state.all_messages[channel].append((f"-- {hour} --", ""))
ui_state.all_messages[channel].extend(messages)
# Replace the channel's messages with the freshly loaded page to avoid duplicates
formatted_messages, packet_ids = _format_db_messages(db_messages, node_names)
ui_state.all_messages[channel] = formatted_messages
ui_state.message_packet_ids[channel] = packet_ids
if db_messages:
ui_state.oldest_message_rowid[channel] = db_messages[0][0]
ui_state.has_older_messages[channel] = has_older
except sqlite3.Error as e:
logging.error(f"SQLite error while loading messages from table '{table_name}': {e}")
@@ -165,6 +213,47 @@ def load_messages_from_db() -> None:
logging.error(f"SQLite error in load_messages_from_db: {e}")
def load_older_messages(channel, page_size: int = MESSAGE_PAGE_SIZE) -> int:
"""Prepend one older page for a channel and return the number of messages loaded."""
before_rowid = ui_state.oldest_message_rowid.get(channel)
if before_rowid is None or not ui_state.has_older_messages.get(channel, False):
return 0
try:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
query = f"""
SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id
FROM {get_table_name(channel)}
WHERE rowid < ? ORDER BY rowid DESC LIMIT ?
"""
db_cursor.execute(query, (before_rowid, page_size + 1))
rows = db_cursor.fetchall()
has_older = len(rows) > page_size
db_messages = list(reversed(rows[:page_size]))
if not db_messages:
ui_state.has_older_messages[channel] = False
return 0
older, older_packet_ids = _format_db_messages(db_messages, _load_node_names(db_cursor))
current = ui_state.all_messages.setdefault(channel, [])
current_packet_ids = ui_state.message_packet_ids.setdefault(channel, [])
while len(current_packet_ids) < len(current):
current_packet_ids.append(None)
# Keep the existing page's leading separator even when the older page
# falls in the same hour. Removing it makes a timestamp that the user
# is looking at jump out of view as soon as another page is loaded.
ui_state.all_messages[channel] = older + current
ui_state.message_packet_ids[channel] = older_packet_ids + current_packet_ids
ui_state.oldest_message_rowid[channel] = db_messages[0][0]
ui_state.has_older_messages[channel] = has_older
return len(db_messages)
except sqlite3.Error as e:
logging.error(f"SQLite error loading older messages for channel '{channel}': {e}")
return 0
def init_nodedb() -> None:
"""Initialize the node database and update it with nodes from the interface."""
@@ -228,7 +317,8 @@ def update_node_info_in_db(
try:
ensure_node_table_exists() # Ensure the table exists before any operation
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote in case of numeric names
@@ -236,6 +326,7 @@ def update_node_info_in_db(
if "chat_archived" not in table_columns:
update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER"
db_cursor.execute(update_table_query)
db_connection.commit()
# Fetch existing values to preserve unchanged fields
db_cursor.execute(f"SELECT * FROM {table_name} WHERE user_id = ?", (user_id,))
@@ -311,7 +402,8 @@ def ensure_node_table_exists() -> None:
def ensure_table_exists(table_name: str, schema: str) -> None:
"""Ensure the given table exists in the database."""
try:
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
create_table_query = f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})"
db_cursor.execute(create_table_query)
@@ -331,7 +423,8 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
:return: The retrieved name or the hex of the user id
"""
try:
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
# Construct table name
@@ -359,7 +452,8 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
def is_chat_archived(user_id: int) -> int:
try:
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
table_name = f"{str(interface_state.myNodeNum)}_nodedb"
nodeinfo_table = f'"{table_name}"'
+2 -1
View File
@@ -141,7 +141,8 @@ def seed_demo_messages() -> None:
ack_type TEXT
"""
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
cursor = db_connection.cursor()
for channel_name, rows in _demo_messages().items():
+46 -1
View File
@@ -1,4 +1,5 @@
import datetime
import re
import time
from typing import Optional, Union
from google.protobuf.message import DecodeError
@@ -156,10 +157,52 @@ def get_time_ago(timestamp):
return "now"
def add_new_message(channel_id, prefix, message):
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()
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}> "
def get_reply_context(reply_id):
"""Find the locally displayed message referred to by a Meshtastic reply ID."""
for channel, packet_ids in ui_state.message_packet_ids.items():
for index, packet_id in enumerate(packet_ids):
if packet_id == reply_id and index < len(ui_state.all_messages.get(channel, [])):
prefix, message = ui_state.all_messages[channel][index]
return build_reply_prefix(prefix, message)
return ""
def add_new_message(channel_id, prefix, message, packet_id=None):
if channel_id not in ui_state.all_messages:
ui_state.all_messages[channel_id] = []
packet_ids = ui_state.message_packet_ids.setdefault(channel_id, [])
while len(packet_ids) < len(ui_state.all_messages[channel_id]):
packet_ids.append(None)
# Timestamp handling
current_timestamp = time.time()
current_hour = datetime.datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00")
@@ -180,10 +223,12 @@ def add_new_message(channel_id, prefix, message):
# Add a new timestamp if it's a new hour
if last_hour != current_hour:
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
packet_ids.append(None)
# Add the message
ts_str = time.strftime("[%H:%M:%S] ")
ui_state.all_messages[channel_id].append((f"{ts_str}{prefix}", message))
packet_ids.append(packet_id)
def parse_protobuf(packet: dict) -> Union[str, dict]:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.5.6"
version = "1.5.11"
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"}
+59
View File
@@ -0,0 +1,59 @@
import unittest
import importlib
import sys
import types
from unittest import mock
import contact.ui.default_config as config
from contact.utilities.singleton import interface_state, ui_state
from tests.test_support import reset_singletons
class BotHandlerTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
sys.modules.setdefault(
"contact.message_handlers.tx_handler",
types.SimpleNamespace(send_message=mock.Mock()),
)
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"))
self.assertTrue(self.bot_handler.is_bot_message("test"))
self.assertFalse(self.bot_handler.is_bot_message("hello"))
def test_is_bot_message_ignores_empty_config_values(self) -> None:
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:
ui_state.bot_mode_enabled = True
interface_state.myNodeNum = 111
packet = {"id": 900, "from": 222, "decoded": {}}
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
)
if __name__ == "__main__":
unittest.main()
+283
View File
@@ -1,4 +1,5 @@
import unittest
from types import SimpleNamespace
from unittest import mock
import contact.ui.default_config as config
@@ -70,6 +71,188 @@ class ContactUiTests(unittest.TestCase):
self.assertFalse(ui_state.redraw_channels)
self.assertFalse(ui_state.redraw_messages)
def test_process_pending_ui_updates_preserves_selection_when_scrolling_to_new_messages(self) -> None:
stdscr = mock.Mock()
ui_state.redraw_messages = True
ui_state.scroll_messages_to_bottom = True
ui_state.preserve_message_selection = True
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
contact_ui.process_pending_ui_updates(stdscr)
draw_messages_window.assert_called_once_with(True, preserve_selection=True)
self.assertFalse(ui_state.preserve_message_selection)
def test_draw_messages_resizes_pad_once(self) -> None:
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[10:00] RX: ", "one"), ("[10:01] RX: ", "two")]}
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (2, 40)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 40)
contact_ui.messages_win.getbegyx.return_value = (0, 0)
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_packetlog_win"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.draw_messages_window()
contact_ui.messages_pad.resize.assert_called_once_with(2, 40)
def test_build_reply_prefix_includes_sender_and_five_character_excerpt(self) -> None:
reply = contact_ui.build_reply_prefix(
"[06:27:25] >> [6] B1G1: ",
"This is a message long enough to be shortened for the reply marker.",
)
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"]
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
ui_state.message_packet_ids = {"Primary": [1234]}
ui_state.selected_message = 0
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "Same to you!")
self.assertEqual(ui_state.reply_id, 1234)
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
def test_handle_ctrl_r_clears_an_existing_reply(self) -> None:
ui_state.current_window = 1
ui_state.reply_id = 1234
ui_state.reply_context = "<Re: B1G1: Good > "
ui_state.reply_id_unavailable = False
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "")
self.assertIsNone(ui_state.reply_id)
self.assertEqual(ui_state.reply_context, "")
self.assertFalse(ui_state.reply_id_unavailable)
def test_handle_ctrl_r_shows_context_when_message_id_is_unavailable(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
ui_state.selected_message = 0
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
self.assertEqual(contact_ui.handle_ctrl_r(""), "")
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
self.assertTrue(ui_state.reply_id_unavailable)
def test_refresh_message_highlight_marks_selected_message(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.message_line_ranges = {"Primary": [(0, 2, 10), (2, 3, 20)]}
ui_state.selected_message = 2
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.refresh_message_highlight()
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20 | contact_ui.curses.A_REVERSE)
def test_refresh_message_highlight_clears_when_messages_pane_is_not_active(self) -> None:
ui_state.current_window = 0
ui_state.highlighted_message_range = (2, 3, 20)
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.refresh_message_highlight()
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20)
self.assertEqual(ui_state.highlighted_message_range, ())
def test_handle_end_moves_messages_viewport_to_bottom(self) -> None:
ui_state.current_window = 1
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
with mock.patch.object(contact_ui, "refresh_message_highlight"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.handle_end()
self.assertEqual(ui_state.selected_message, 99)
self.assertGreater(ui_state.start_index[1], 0)
def test_message_selection_can_reach_last_rendered_line(self) -> None:
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
contact_ui.set_message_selection(99)
self.assertEqual(ui_state.selected_message, 99)
self.assertEqual(ui_state.start_index[1], 90)
def test_switching_to_messages_redraws_at_bottom(self) -> None:
ui_state.current_window = 0
ui_state.single_pane_mode = False
with mock.patch.object(contact_ui, "refresh_main_window"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "refresh_message_highlight"):
contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT)
draw_messages_window.assert_called_once_with(True)
def test_move_message_selection_skips_all_lines_of_wrapped_message(self) -> None:
ui_state.channel_list = ["Primary"]
ui_state.message_line_ranges = {"Primary": [(0, 3, 10), (3, 4, 20), (4, 6, 30)]}
ui_state.selected_message = 1
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (6, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
contact_ui.move_message_selection(1)
self.assertEqual(ui_state.selected_message, 3)
contact_ui.move_message_selection(1)
self.assertEqual(ui_state.selected_message, 5)
contact_ui.move_message_selection(-1)
self.assertEqual(ui_state.selected_message, 3)
def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101, 202]
ui_state.selected_node = 1
@@ -200,3 +383,103 @@ class ContactUiTests(unittest.TestCase):
contact_ui.refresh_pad(1)
contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)
def test_search_ignores_no_input_from_curses(self) -> None:
ui_state.node_list = [101]
ui_state.selected_node = 0
contact_ui.entry_win = mock.Mock()
contact_ui.entry_win.get_wch.side_effect = contact_ui.curses.error("no input")
with mock.patch.object(contact_ui, "draw_centered_text_field"):
with mock.patch.object(contact_ui, "get_color", return_value=0):
contact_ui.search(2)
contact_ui.entry_win.timeout.assert_has_calls([mock.call(-1), mock.call(200)])
contact_ui.entry_win.erase.assert_called()
def test_f5_node_details_ignores_no_input_from_curses(self) -> None:
stdscr = mock.Mock()
ui_state.node_list = [101]
ui_state.selected_node = 0
ui_state.current_window = 2
dialog_win = mock.Mock()
dialog_win.getch.side_effect = [contact_ui.curses.error("no input"), 27]
msg_win = mock.Mock()
dialog_win.derwin.return_value = msg_win
interface = mock.Mock()
interface.nodesByNum = {
101: {
"num": 101,
"user": {
"longName": "Test Node",
"shortName": "TN",
"hwModel": "T-Beam",
"role": "CLIENT",
"publicKey": "abc",
},
}
}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui.curses, "LINES", 24, create=True):
with mock.patch.object(contact_ui.curses, "COLS", 80, create=True):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui.curses, "update_lines_cols"):
with mock.patch.object(contact_ui.curses, "doupdate"):
with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_node_selection"):
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.handle_f5_key(stdscr)
self.assertEqual(dialog_win.getch.call_count, 2)
handle_resize.assert_called_once_with(stdscr, False)
def test_f5_node_details_tolerates_none_metrics(self) -> None:
stdscr = mock.Mock()
ui_state.node_list = [101]
ui_state.selected_node = 0
ui_state.current_window = 2
dialog_win = mock.Mock()
dialog_win.getch.return_value = 27
msg_win = mock.Mock()
dialog_win.derwin.return_value = msg_win
interface = mock.Mock()
interface.nodesByNum = {
101: {
"num": 101,
"snr": None,
"hopsAway": None,
"deviceMetrics": {
"batteryLevel": None,
"channelUtilization": None,
"airUtilTx": None,
"uptimeSeconds": None,
},
"user": {
"longName": "Test Node",
"shortName": "TN",
"hwModel": "T-Beam",
"role": "CLIENT",
"publicKey": "abc",
},
}
}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui.curses, "LINES", 24, create=True):
with mock.patch.object(contact_ui.curses, "COLS", 80, create=True):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui.curses, "update_lines_cols"):
with mock.patch.object(contact_ui.curses, "doupdate"):
with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_node_selection"):
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.handle_f5_key(stdscr)
handle_resize.assert_called_once_with(stdscr, False)
+85
View File
@@ -45,6 +45,38 @@ class DbHandlerTests(unittest.TestCase):
self.assertEqual(row, ("123", "hello", "Ack"))
def test_message_ids_are_migrated_and_reloaded_with_history(self) -> None:
db_handler.ensure_table_exists(
'"123_Primary_messages"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.execute(
'INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)',
("456", "original message", 1700000000, None),
)
db_handler.save_message_to_db("Primary", "456", "reply", packet_id=902, reply_id=901)
with sqlite3.connect(config.db_file_path) as conn:
columns = {row[1] for row in conn.execute('PRAGMA table_info("123_Primary_messages")')}
stored_ids = conn.execute(
'SELECT packet_id, reply_id FROM "123_Primary_messages" WHERE message_text = ?', ("reply",)
).fetchone()
conn.execute(
'UPDATE "123_Primary_messages" SET packet_id = ? WHERE message_text = ?', (901, "original message")
)
conn.commit()
db_handler.load_messages_from_db()
self.assertTrue({"packet_id", "reply_id"}.issubset(columns))
self.assertEqual(stored_ids, (902, 901))
self.assertEqual([packet_id for packet_id in ui_state.message_packet_ids["Primary"] if packet_id], [901, 902])
self.assertEqual(
ui_state.all_messages["Primary"][-1][1],
f"<Re: {decimal_to_hex(456)}: origi> reply",
)
def test_update_node_info_in_db_fills_defaults_and_preserves_existing_values(self) -> None:
db_handler.update_node_info_in_db(999, short_name="ABCD")
@@ -112,6 +144,59 @@ class DbHandlerTests(unittest.TestCase):
self.assertTrue(any("RM:" in prefix for prefix, _ in messages))
self.assertEqual(ui_state.all_messages[789][-1][1], "hidden")
def test_message_history_is_loaded_in_bounded_pages(self) -> None:
db_handler.update_node_info_in_db(456, long_name="Remote Node", short_name="RM")
db_handler.ensure_table_exists(
'"123_Primary_messages"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.executemany(
'INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)',
[("456", f"message-{i}", 1700000000 + i, None) for i in range(7)],
)
db_handler.load_messages_from_db(page_size=3)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(loaded_text, ["message-4", "message-5", "message-6"])
self.assertTrue(ui_state.has_older_messages["Primary"])
self.assertEqual(db_handler.load_older_messages("Primary", page_size=3), 3)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(
loaded_text,
["message-1", "message-2", "message-3", "message-4", "message-5", "message-6"],
)
self.assertTrue(ui_state.has_older_messages["Primary"])
separators = [prefix for prefix, _ in ui_state.all_messages["Primary"] if prefix.startswith("--")]
self.assertEqual(len(separators), 2)
self.assertEqual(separators[0], separators[1])
self.assertEqual(db_handler.load_older_messages("Primary", page_size=3), 1)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(loaded_text, [f"message-{i}" for i in range(7)])
self.assertFalse(ui_state.has_older_messages["Primary"])
separators = [prefix for prefix, _ in ui_state.all_messages["Primary"] if prefix.startswith("--")]
self.assertEqual(len(separators), 3)
def test_message_table_channel_name_may_contain_underscores(self) -> None:
db_handler.ensure_node_table_exists()
db_handler.ensure_table_exists(
'"123_My_Channel_messages"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.execute(
'INSERT INTO "123_My_Channel_messages" VALUES (?, ?, ?, ?)',
("123", "hello", 1700000000, None),
)
db_handler.load_messages_from_db()
self.assertIn("My_Channel", ui_state.channel_list)
self.assertEqual(ui_state.all_messages["My_Channel"][-1][1], "hello")
def test_init_nodedb_inserts_nodes_from_interface(self) -> None:
interface_state.interface = build_demo_interface()
interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM
+88
View File
@@ -0,0 +1,88 @@
import unittest
from unittest import mock
from contact.ui import dialog as dialog_module
from contact.utilities.singleton import menu_state, ui_state
class _FakeWindow:
def __init__(self, height: int, width: int) -> None:
self.height = height
self.width = width
self.children = []
self.added_strings = []
self._getch_values = [10]
def erase(self) -> None:
return None
def bkgd(self, *_args) -> None:
return None
def attrset(self, *_args) -> None:
return None
def border(self, *_args) -> None:
return None
def addstr(self, y: int, x: int, text: str, *_args) -> None:
self.added_strings.append((y, x, text))
def derwin(self, height: int, width: int, _y: int, _x: int):
child = _FakeWindow(height, width)
self.children.append(child)
return child
def noutrefresh(self) -> None:
return None
def keypad(self, *_args) -> None:
return None
def timeout(self, *_args) -> None:
return None
def getch(self) -> int:
return self._getch_values.pop(0) if self._getch_values else -1
def refresh(self) -> None:
return None
def getmaxyx(self):
return (self.height, self.width)
class DialogTests(unittest.TestCase):
def setUp(self) -> None:
self.previous_window = ui_state.current_window
self.previous_start_index = list(ui_state.start_index)
self.previous_need_redraw = menu_state.need_redraw
def tearDown(self) -> None:
ui_state.current_window = self.previous_window
ui_state.start_index = self.previous_start_index
menu_state.need_redraw = self.previous_need_redraw
def test_dialog_renders_full_message_when_width_is_sufficient(self) -> None:
root = _FakeWindow(5, 33)
ui_state.current_window = 0
ui_state.start_index = [0, 0, 0]
menu_state.need_redraw = False
with mock.patch.object(dialog_module, "t_text", side_effect=lambda text: text):
with mock.patch.object(dialog_module, "get_color", return_value=0):
with mock.patch.object(dialog_module, "draw_main_arrows"):
with mock.patch.object(dialog_module.curses, "update_lines_cols"):
with mock.patch.object(dialog_module.curses, "doupdate"):
with mock.patch.object(dialog_module.curses, "LINES", 24, create=True):
with mock.patch.object(dialog_module.curses, "COLS", 80, create=True):
with mock.patch.object(dialog_module.curses, "newwin", return_value=root):
dialog_module.dialog("Bot Responder", "Bot responder is now Enabled.")
self.assertTrue(root.children)
message_text = [text for _y, _x, text in root.children[0].added_strings]
self.assertIn("Bot responder is now Enabled.", message_text)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -5,6 +5,7 @@ from unittest import mock
import contact.ui.default_config as config
from contact.utilities import i18n
from contact.utilities.ini_utils import parse_ini_file
from tests.test_support import restore_config, snapshot_config
@@ -55,3 +56,20 @@ class I18nTests(unittest.TestCase):
config.language = "ru"
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:
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.catch_words",
"app_settings.ping_bot.response_word",
}
for language in config.get_localisation_options():
field_mapping, _ = parse_ini_file(config.get_localisation_file(language))
self.assertTrue(required_keys.issubset(field_mapping), msg=f"Missing bot translation keys in {language}")
+66 -3
View File
@@ -40,13 +40,50 @@ class RxHandlerTests(unittest.TestCase):
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
rx_handler.on_receive(packet, interface=None)
self.assertEqual(request_ui_redraw.call_args_list, [mock.call(nodes=True), mock.call(messages=True, scroll_messages_to_bottom=True)])
self.assertEqual(
request_ui_redraw.call_args_list,
[
mock.call(nodes=True),
mock.call(
messages=True,
scroll_messages_to_bottom=True,
preserve_message_selection=False,
),
],
)
add_notification.assert_not_called()
save_message_to_db.assert_called_once_with("Primary", 222, "hello")
save_message_to_db.assert_called_once_with("Primary", 222, "hello", packet_id=None, reply_id=None)
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello")
self.assertIn("SAT2:", ui_state.all_messages["Primary"][-1][0])
self.assertIn("[2]", ui_state.all_messages["Primary"][-1][0])
def test_on_receive_preserves_message_selection_while_showing_new_message(self) -> None:
interface_state.myNodeNum = 111
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
ui_state.selected_channel = 0
packet = {
"from": 222,
"to": 999,
"channel": 0,
"hopStart": 1,
"hopLimit": 1,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
with mock.patch.object(rx_handler, "save_message_to_db"):
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
rx_handler.on_receive(packet, interface=None)
request_ui_redraw.assert_called_once_with(
messages=True,
scroll_messages_to_bottom=True,
preserve_message_selection=True,
)
def test_on_receive_direct_message_adds_channel_and_notification(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
@@ -74,7 +111,33 @@ class RxHandlerTests(unittest.TestCase):
request_ui_redraw.assert_called_once_with(channels=True)
add_notification.assert_called_once_with(1)
update_node_info_in_db.assert_called_once_with(222, chat_archived=False)
save_message_to_db.assert_called_once_with(222, 222, "dm")
save_message_to_db.assert_called_once_with(222, 222, "dm", packet_id=None, reply_id=None)
def test_on_receive_displays_context_for_native_reply_id(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[06:00:00] >> SAT2: ", "hello world")]}
ui_state.message_packet_ids = {"Primary": [900]}
ui_state.selected_channel = 0
packet = {
"id": 901,
"from": 222,
"to": 999,
"channel": 0,
"hopStart": 1,
"hopLimit": 1,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hi", "replyId": 900},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
with mock.patch.object(rx_handler, "request_ui_redraw"):
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
with mock.patch.object(rx_handler, "get_name_from_database", return_value="NODE"):
rx_handler.on_receive(packet, interface=None)
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "<Re: SAT2: hello> hi")
self.assertEqual(ui_state.message_packet_ids["Primary"][-1], 901)
save_message_to_db.assert_called_once_with("Primary", 222, "hi", packet_id=901, reply_id=900)
def test_on_receive_trims_packet_buffer_even_when_packet_is_undecoded(self) -> None:
ui_state.packet_buffer = list(range(25))
+18 -3
View File
@@ -42,7 +42,7 @@ class TxHandlerTests(unittest.TestCase):
onResponse=tx_handler.onAckNak,
channelIndex=0,
)
save_message_to_db.assert_called_once_with("Primary", 111, "hello")
save_message_to_db.assert_called_once_with("Primary", 111, "hello", packet_id="req-1", reply_id=None)
self.assertEqual(tx_handler.ack_naks["req-1"]["channel"], "Primary")
self.assertEqual(tx_handler.ack_naks["req-1"]["messageIndex"], 1)
self.assertEqual(tx_handler.ack_naks["req-1"]["timestamp"], 999)
@@ -70,12 +70,27 @@ class TxHandlerTests(unittest.TestCase):
)
self.assertEqual(tx_handler.ack_naks["req-2"]["channel"], 222)
def test_send_message_uses_native_reply_id_but_only_displays_reply_context_locally(self) -> None:
interface = mock.Mock()
interface.sendText.return_value = SimpleNamespace(id="req-3")
interface_state.interface = interface
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
with mock.patch.object(tx_handler, "save_message_to_db", return_value=123):
tx_handler.send_message("hello", channel=0, reply_id=77, reply_context="<Re: NODE: hello> ")
self.assertEqual(interface.sendText.call_args.kwargs["text"], "hello")
self.assertEqual(interface.sendText.call_args.kwargs["replyId"], 77)
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "<Re: NODE: hello> hello")
def test_on_ack_nak_updates_message_for_explicit_ack(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
ui_state.all_messages = {"Primary": [("pending", "hello")]}
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55}
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55, "dbMessage": "hello"}
packet = {"from": 222, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}
@@ -94,7 +109,7 @@ class TxHandlerTests(unittest.TestCase):
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
ui_state.all_messages = {"Primary": [("pending", "hello")]}
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55}
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55, "dbMessage": "hello"}
packet = {"from": 111, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}