Compare commits

...
17 Commits
Author SHA1 Message Date
pdxlocations 424e10af1e Bump version to 1.5.13 in pyproject.toml 2026-07-24 10:17:55 -07:00
pdxlocations 986cf817f3 Merge pull request #286 from pdxlocations:canned-messages-and-ringtone
Add Canned Messages and Ringone
2026-07-24 10:17:18 -07:00
pdxlocations c699845c81 Improve error handling in API responses
Enhance the error handling mechanism in API responses to provide clearer and more informative messages to users. This change aims to improve user experience and debugging capabilities.
2026-07-24 10:14:49 -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
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
24 changed files with 770 additions and 156 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.
+6 -6
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,11 +76,11 @@ 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", ""
@@ -91,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"
@@ -129,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."
@@ -363,6 +361,7 @@ override_console_serial_port, "Override console serial port", "If set to true, t
[module.external_notification]
title, "External Notification"
ringtone, "Ringtone", "RTTTL ringtone played by supported external-notification hardware."
enabled, "Enabled", "Enables the module."
output_ms, "Length", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause."
output, "Output GPIO", "Define the output pin GPIO setting Defaults to EXT_NOTIFY_OUT if set for the board. In standalone devices this pin should drive the LED to match the UI."
@@ -413,6 +412,7 @@ device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemet
[module.canned_message]
title, "Canned Message"
messages, "Messages", "Pipe-separated canned messages, for example: Hi|On my way|Yes|No."
rotary1_enabled, "Rotary encoder enabled", "Enable the default rotary encoder."
inputbroker_pin_a, "Input broker pin A", "GPIO Pin Value (1-39) For encoder port A."
inputbroker_pin_b, "Input broker pin B", "GPIO Pin Value (1-39) For encoder port B."
+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('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
+22 -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,
@@ -183,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,
}
+204 -68
View File
@@ -5,7 +5,14 @@ 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, add_new_message
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
@@ -41,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
@@ -48,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:
@@ -58,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
@@ -71,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
@@ -370,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:
@@ -426,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)
@@ -446,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)
@@ -488,7 +507,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)
@@ -501,8 +521,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)
@@ -514,9 +534,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))
@@ -528,11 +547,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))
@@ -555,6 +571,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:
@@ -585,6 +604,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:
@@ -612,6 +634,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:
@@ -621,7 +652,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()
@@ -920,6 +959,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."""
@@ -933,11 +1015,11 @@ 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"),
@@ -949,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):
@@ -1152,7 +1208,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:
@@ -1163,10 +1219,12 @@ 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]
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)
for line in wrapped_lines:
@@ -1179,20 +1237,29 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
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)
@@ -1203,6 +1270,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
@@ -1293,31 +1393,67 @@ def scroll_messages(direction: int) -> None:
refresh_pad(1)
return
ui_state.selected_message += direction
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
+18
View File
@@ -609,6 +609,24 @@ def settings_menu(stdscr: object, interface: object) -> None:
# Fetch human-readable name from field_mapping
human_readable_name = field_mapping.get(full_key, selected_option)
if field is None and selected_option in ("ringtone", "messages"):
is_ringtone = selected_option == "ringtone"
getter_name = "get_ringtone" if is_ringtone else "get_canned_message"
setter_name = "set_ringtone" if is_ringtone else "set_canned_message"
getter = getattr(interface.localNode, getter_name, None)
setter = getattr(interface.localNode, setter_name, None)
fetched_value = getter() if callable(getter) else None
current_value = fetched_value if fetched_value is not None else current_value
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, None
)
if new_value is not None and new_value != current_value and callable(setter):
setter(new_value)
menu_state.current_menu[selected_option] = (field, new_value)
menu_state.start_index.pop()
menu_state.need_redraw = True
continue
if selected_option in ["longName", "shortName", "isLicensed"]:
if selected_option in ["longName", "shortName"]:
new_value = get_text_input(
+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":
+13 -1
View File
@@ -118,7 +118,19 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
# Add Module Settings
module = module_config_pb2.ModuleConfig()
current_module_config = interface.localNode.moduleConfig if interface else None
menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config)
module_settings = extract_fields(module, current_module_config)
if interface and interface.localNode:
# These values use dedicated admin requests rather than ModuleConfig
# fields, so expose them alongside their related module settings.
module_settings.setdefault("external_notification", {})["ringtone"] = (
None,
getattr(interface.localNode, "ringtone", "") or "",
)
module_settings.setdefault("canned_message", {})["messages"] = (
None,
getattr(interface.localNode, "cannedPluginMessage", "") or "",
)
menu_structure["Main Menu"]["Module Settings"] = module_settings
# Add App Settings
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
+7 -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
@@ -40,8 +38,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
+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)
+52 -20
View File
@@ -4,7 +4,7 @@ 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
@@ -21,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)
@@ -30,21 +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, 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
@@ -79,9 +97,10 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
def _format_db_messages(db_messages, node_names):
"""Format database rows, adding one timestamp separator per hour."""
"""Format database rows and packet IDs, adding one timestamp separator per hour."""
hourly_messages = {}
for _rowid, user_id, message, timestamp, ack_type in db_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
@@ -108,13 +127,24 @@ def _format_db_messages(db_messages, node_names):
f"{ts_str} {config.message_prefix} {node_names.get(str(user_id), fallback_name)}: ",
sanitized_message,
)
hourly_messages.setdefault(hour, []).append(formatted_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} --", ""))
formatted.extend(messages)
return formatted
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):
@@ -142,14 +172,10 @@ def load_messages_from_db(page_size: int = MESSAGE_PAGE_SIZE) -> None:
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)
db_connection.commit()
_ensure_message_columns(db_cursor, quoted_table_name)
query = f"""
SELECT rowid, user_id, message_text, timestamp, ack_type
SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id
FROM {quoted_table_name}
ORDER BY rowid DESC LIMIT ?
"""
@@ -173,7 +199,9 @@ def load_messages_from_db(page_size: int = MESSAGE_PAGE_SIZE) -> None:
ui_state.channel_list.append(channel)
# Replace the channel's messages with the freshly loaded page to avoid duplicates
ui_state.all_messages[channel] = _format_db_messages(db_messages, node_names)
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
@@ -196,7 +224,7 @@ def load_older_messages(channel, page_size: int = MESSAGE_PAGE_SIZE) -> int:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
query = f"""
SELECT rowid, user_id, message_text, timestamp, ack_type
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 ?
"""
@@ -208,12 +236,16 @@ def load_older_messages(channel, page_size: int = MESSAGE_PAGE_SIZE) -> int:
ui_state.has_older_messages[channel] = False
return 0
older = _format_db_messages(db_messages, _load_node_names(db_cursor))
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)
+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.8"
version = "1.5.13"
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()
+164
View File
@@ -1,4 +1,5 @@
import unittest
from types import SimpleNamespace
from unittest import mock
import contact.ui.default_config as config
@@ -70,10 +71,23 @@ 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()
@@ -89,6 +103,156 @@ class ContactUiTests(unittest.TestCase):
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
+32
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")
+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
@@ -26,3 +26,21 @@ class MenusTests(unittest.TestCase):
self.assertLess(keys.index("Factory Reset"), keys.index("factory_reset_config"))
self.assertEqual(keys[keys.index("Factory Reset") + 1], "factory_reset_config")
def test_module_settings_include_ringtone_and_canned_messages(self) -> None:
local_node = SimpleNamespace(
localConfig=config_pb2.Config(),
moduleConfig=module_config_pb2.ModuleConfig(),
ringtone="tone",
cannedPluginMessage="Hi|Bye",
getChannelByChannelIndex=lambda _: None,
)
interface = SimpleNamespace(
localNode=local_node,
getMyNodeInfo=lambda: {"position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0}},
)
module_settings = generate_menu_from_protobuf(interface)["Main Menu"]["Module Settings"]
self.assertEqual(module_settings["external_notification"]["ringtone"], (None, "tone"))
self.assertEqual(module_settings["canned_message"]["messages"], (None, "Hi|Bye"))
+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"}}}
+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()