mirror of
https://github.com/pdxlocations/contact.git
synced 2026-10-31 16:21:57 +01:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72d959a3c1 | ||
|
|
73b31a5124 | ||
|
|
d403d2cea5 | ||
|
|
59a7b5b488 | ||
|
|
5baef73c3c | ||
|
|
46a73fbc65 | ||
|
|
0145d84f3b | ||
|
|
ff656f20ad | ||
|
|
eef321cac7 | ||
|
|
cc33716222 | ||
|
|
7efc1a640d | ||
|
|
b54e467ef0 | ||
|
|
8c01f8fc17 | ||
|
|
c6164af582 | ||
|
|
52f42591d2 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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, "Ответное слово", "Ответное слово бота."
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+223
-77
@@ -5,12 +5,19 @@ 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
|
||||
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
|
||||
@@ -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:
|
||||
@@ -1162,18 +1218,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")
|
||||
@@ -1182,18 +1235,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)
|
||||
@@ -1204,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
|
||||
@@ -1283,31 +1382,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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,6 +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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+148
-63
@@ -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,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
|
||||
@@ -75,7 +96,66 @@ 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, timeout=10.0) as db_connection:
|
||||
@@ -85,27 +165,31 @@ def load_messages_from_db() -> None:
|
||||
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)
|
||||
db_connection.commit()
|
||||
_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
|
||||
@@ -114,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}")
|
||||
@@ -169,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."""
|
||||
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.5.8"
|
||||
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"}
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-7
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user