Compare commits

...
27 Commits
Author SHA1 Message Date
pdxlocations eef321cac7 Bump version to 1.5.9 in pyproject.toml 2026-07-18 22:25:00 -07:00
pdxlocations cc33716222 Merge pull request #281 from pdxlocations/replies
Support replies
2026-07-18 22:23:33 -07:00
pdxlocations 7efc1a640d Enhance messaging features with reply support and message selection preservation
- Updated `send_message` to include reply ID and context for native replies.
- Modified `onAckNak` to correctly update the database with the message context.
- Implemented reply prefix formatting in `build_reply_prefix` for better message clarity.
- Added functionality to preserve message selection when new messages are received.
- Enhanced database handling to store packet IDs and reply IDs for messages.
- Updated UI state to manage reply context and message highlighting effectively.
- Improved tests to cover new reply features and message selection behavior.
2026-07-18 22:21:55 -07:00
pdxlocations b54e467ef0 Merge pull request #280 from pdxlocations:message-loading
Slowness with busy channels
2026-07-18 21:34:40 -07:00
pdxlocations 8c01f8fc17 Reduce MESSAGE_PAGE_SIZE to improve message loading efficiency and update tests to verify separator consistency. 2026-07-18 21:34:07 -07:00
pdxlocations c6164af582 Refactor message loading logic to replace channel messages and avoid duplicates 2026-07-18 21:28:57 -07:00
pdxlocations 52f42591d2 Slowness with busy channels
Fixes #279
2026-07-18 21:24:32 -07:00
pdxlocations 69cb568d2c bump version to 1.5.8 in pyproject.toml 2026-05-07 13:50:36 -07:00
pdxlocations 68b08787de Enhance error handling in contact UI and improve metrics display logic 2026-05-07 13:50:12 -07:00
pdxlocations 80ede60153 bump version to 1.5.7 in pyproject.toml 2026-03-28 22:42:34 -07:00
pdxlocations 046ede23a2 Merge pull request #265 from stason200711/main
Allow overriding DATA_DIR via environment variable and fix file descriptor leaks
2026-03-28 22:41:47 -07:00
pdxlocations 0ad39fd6a0 Add Ping Bot Config to Settings 2026-03-28 22:35:34 -07:00
pdxlocations 6721874937 add bot strings to i18n and fix dilogue width 2026-03-28 22:22:41 -07:00
pdxlocations 8ee21b1973 Merge pull request #266 from SpudGunMan:bot
auto respond bot
2026-03-28 22:10:26 -07:00
Kelly 0760d6d0ca auto respond bot
Added an optional auto-responder bot that listens for configurable trigger words (from localisation) and sends a response with available link metadata (like SNR/RSSI/hops/transport) The feature is toggleable from the UI shortcut ctl+B
2026-03-28 19:57:22 -07:00
stason200711 cd0b114f13 Update db_handler.py 2026-03-28 09:52:37 +03:00
stason200711 2d4b407caa Update default_config.py
Allow overriding DATA_DIR via environment variable
2026-03-26 09:55:00 +03:00
stason200711 80b9311959 Update demo_data.py
Fix file descriptor leaks
2026-03-26 09:52:08 +03:00
stason200711 da5f5bff39 Update db_handler.py
Fix file descriptor leaks
2026-03-25 23:20:36 +03:00
pdxlocations bc69c709ff Bump version to 1.5.6 in pyproject.toml 2026-03-22 21:37:12 -07:00
pdxlocations 6453865cba Merge pull request #262 from SpudGunMan:main
refactor exit
2026-03-22 21:36:27 -07:00
pdxlocations 090e89d92c Merge pull request #263 from pdxlocations:add-french-language
Add French localization file for user interface and settings
2026-03-22 21:16:15 -07:00
pdxlocations 07716c1719 Add French localization file for user interface and settings 2026-03-22 21:15:58 -07:00
Kelly da5b102047 Update __main__.py 2026-03-22 14:44:22 -07:00
pdxlocations 1668b68c4f Bump version to 1.5.5 in pyproject.toml 2026-03-21 22:08:57 -07:00
pdxlocations fd4b9e2174 Add tests for handling interface absence and keyboard interrupts in start function 2026-03-21 22:08:41 -07:00
pdxlocations 8f32e2c99c Merge pull request #260 from pdxlocations:reset
Add factory reset config option and tryfix factory reset
2026-03-21 21:28:29 -07:00
25 changed files with 1574 additions and 215 deletions

No files matched your search

+1
View File
@@ -60,6 +60,7 @@ For smaller displays you may wish to enable `single_pane_mode`:
- `CTRL` + `t` or `F4` = With the Node List highlighted, send a traceroute to the selected node
- `F5` = Display a node's info
- `CTRL` + `f` = With the Node List highlighted, favorite the selected node
- `CTRL` + `b` = Enable/Disable Autoresponder Bot (ping / pong)
- `CTRL` + `g` = With the Node List highlighted, ignore the selected node
- `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user.
- `CTRL` + `d` = With the Note List highlghted, remove a node from your nodedb.
+44 -8
View File
@@ -57,6 +57,8 @@ logging.basicConfig(
app_state.lock = threading.Lock()
DEFAULT_CLOSE_TIMEOUT_SECONDS = 5.0
# ------------------------------------------------------------------------------
# Main Program Logic
@@ -72,11 +74,36 @@ def prompt_region_if_unset(args: object, stdscr: Optional[curses.window] = None)
interface_state.interface = reconnect_interface(args)
def close_interface(interface: object) -> None:
def close_interface(interface: object, timeout_seconds: float = DEFAULT_CLOSE_TIMEOUT_SECONDS) -> bool:
if interface is None:
return
with contextlib.suppress(Exception):
interface.close()
return True
close_errors = []
def _close_target() -> None:
try:
interface.close()
except BaseException as error: # Keep shutdown resilient even for KeyboardInterrupt/SystemExit from libraries.
close_errors.append(error)
close_thread = threading.Thread(target=_close_target, name="meshtastic-interface-close", daemon=True)
close_thread.start()
close_thread.join(timeout_seconds)
if close_thread.is_alive():
logging.warning("Timed out closing interface after %.1fs; continuing shutdown", timeout_seconds)
return False
if not close_errors:
return True
error = close_errors[0]
if isinstance(error, KeyboardInterrupt):
logging.info("Interrupted while closing interface; continuing shutdown")
return True
logging.warning("Ignoring error while closing interface: %r", error)
return True
def interface_is_ready(interface: object) -> bool:
@@ -205,23 +232,32 @@ def start() -> None:
setup_parser().print_help()
sys.exit(0)
interrupted = False
fatal_error = None
try:
curses.wrapper(main)
close_interface(interface_state.interface)
except KeyboardInterrupt:
interrupted = True
logging.info("User exited with Ctrl+C")
close_interface(interface_state.interface)
sys.exit(0)
except Exception as e:
fatal_error = e
logging.critical("Fatal error", exc_info=True)
try:
curses.endwin()
except Exception:
pass
print("Fatal error:", e)
finally:
close_interface(interface_state.interface)
if fatal_error is not None:
print("Fatal error:", fatal_error)
traceback.print_exc()
sys.exit(1)
if interrupted:
sys.exit(0)
if __name__ == "__main__":
start()
+15
View File
@@ -67,6 +67,8 @@ dialog.traceroute_not_sent_body, "Please wait {seconds} seconds before sending a
dialog.traceroute_sent_title, "Traceroute Sent To: {name}", ""
dialog.traceroute_sent_body, "Results will appear in messages window.", ""
dialog.help_title, "Help - Shortcut Keys", ""
dialog.reply_unavailable_title, "Reply unavailable", ""
dialog.reply_unavailable_body, "This message has no packet ID, so Contact cannot send a native Meshtastic reply.", ""
help.scroll, "Up/Down = Scroll", ""
help.switch_window, "Left/Right = Switch window", ""
help.jump_windows, "F1/F2/F3 = Jump to Channel/Messages/Nodes", ""
@@ -74,10 +76,12 @@ help.enter, "ENTER = Send / Select", ""
help.settings, "` or F12 = Settings", ""
help.quit, "ESC = Quit", ""
help.packet_log, "Ctrl+P = Toggle Packet Log", ""
help.reply, "Ctrl+R = Reply to message at cursor", ""
help.traceroute, "Ctrl+T or F4 = Traceroute", ""
help.node_info, "F5 = Full node info", ""
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
help.favorite, "Ctrl+F = Favorite", ""
help.bot_responder, "Ctrl+B = Toggle Bot Responder", ""
help.ignore, "Ctrl+G = Ignore", ""
help.search, "Ctrl+/ or / = Search", ""
help.help, "Ctrl+K = Help", ""
@@ -90,6 +94,11 @@ confirm.remove_ignored, "Remove {name} from Ignored?", ""
confirm.region_unset, "Your region is UNSET. Set it now?", ""
dialog.resize_title, "Resize Terminal", ""
dialog.resize_body, "Please resize the terminal to at least {rows} rows.", ""
bot.status.enabled, "Enabled", ""
bot.status.disabled, "Disabled", ""
bot.dialog.title, "Bot Responder", ""
bot.dialog.body, "Bot responder is now {status}.", ""
bot.status.message, "Bot responder is now {status}.", ""
[User Settings]
user, "User"
@@ -116,10 +125,16 @@ nak_str, "NAK", ""
ack_unknown_str, "ACK (unknown)", ""
node_sort, "Node sort", ""
theme, "Theme", ""
ping_bot, "Ping Bot", ""
COLOR_CONFIG_DARK, "Theme colors (dark)", ""
COLOR_CONFIG_LIGHT, "Theme colors (light)", ""
COLOR_CONFIG_GREEN, "Theme colors (green)", ""
[app_settings.ping_bot]
title, "Ping Bot", ""
catch_words, "Catch words", "Semicolon-separated bot trigger words."
response_word, "Response word", "Bot response word."
[app_settings.color_config]
default, "Default", ""
background, "Background", ""
+209
View File
@@ -0,0 +1,209 @@
##field_name, "Nom du champ lisible avec première lettre en majuscule", "Texte d'aide avec [warning]avertissements[/warning], [note]notes[/note], [underline]soulignements[/underline], \033[31mcodes couleur ANSI\033[0m et \nsauts de ligne."
Main Menu, "Menu principal", ""
User Settings, "Paramètres utilisateur", ""
Channels, "Canaux", ""
Radio Settings, "Paramètres radio", ""
Module Settings, "Paramètres des modules", ""
App Settings, "Paramètres de l'application", ""
Export Config File, "Exporter le fichier de configuration", ""
Load Config File, "Charger le fichier de configuration", ""
Config URL, "URL de configuration", ""
Reboot, "Redémarrer", ""
Reset Node DB, "Réinitialiser la base de données des nœuds", ""
Shutdown, "Éteindre", ""
Factory Reset, "Réinitialisation d'usine", ""
factory_reset_config, "Réinitialiser la configuration d'usine", ""
Exit, "Quitter", ""
Yes, "Oui", ""
No, "Non", ""
Cancel, "Annuler", ""
[ui]
save_changes, "Enregistrer les modifications", ""
dialog.invalid_input, "Entrée invalide", ""
prompt.enter_new_value, "Entrer une nouvelle valeur : ", ""
error.value_empty, "La valeur ne peut pas être vide.", ""
error.value_exact_length, "La valeur doit comporter exactement {length} caractères.", ""
error.value_min_length, "La valeur doit comporter au moins {length} caractères.", ""
error.value_max_length, "La valeur ne doit pas dépasser {length} caractères.", ""
error.digits_only, "Seuls les chiffres (0-9) sont autorisés.", ""
error.number_range, "Entrez un nombre entre {min_value} et {max_value}.", ""
error.float_invalid, "Doit être un nombre à virgule flottante valide.", ""
prompt.edit_admin_keys, "Modifier jusqu'à 3 clés administrateur :", ""
label.admin_key, "Clé administrateur", ""
error.admin_key_invalid, "Erreur : chaque clé doit être en Base64 valide et faire 32 octets !", ""
prompt.edit_values, "Modifier jusqu'à 3 valeurs :", ""
label.value, "Valeur", ""
prompt.enter_ip, "Entrez une adresse IP (xxx.xxx.xxx.xxx) :", ""
label.current, "Actuel", ""
label.new_value, "Nouvelle valeur", ""
label.editing, "Modification de {label}", ""
label.current_value, "Valeur actuelle :", ""
error.ip_invalid, "Adresse IP invalide. Réessayez.", ""
prompt.select_foreground_color, "Sélectionner la couleur de premier plan pour {label}", ""
prompt.select_background_color, "Sélectionner la couleur d'arrière-plan pour {label}", ""
prompt.select_value, "Sélectionner {label}", ""
confirm.save_before_exit, "Vous avez des modifications non enregistrées. Enregistrer avant de quitter ?", ""
prompt.config_filename, "Entrez un nom de fichier pour le fichier de configuration", ""
confirm.overwrite_file, "{filename} existe déjà. Écraser ?", ""
dialog.config_saved_title, "Fichier de configuration enregistré :", ""
dialog.no_config_files, " Aucun fichier de configuration trouvé. Exportez-en un d'abord.", ""
prompt.choose_config_file, "Choisissez un fichier de configuration", ""
confirm.load_config_file, "Êtes-vous sûr de vouloir charger {filename} ?", ""
prompt.config_url_current, "L'URL de configuration est actuellement : {value}", ""
confirm.load_config_url, "Êtes-vous sûr de vouloir charger cette configuration ?", ""
confirm.reboot, "Êtes-vous sûr de vouloir redémarrer ?", ""
confirm.reset_node_db, "Êtes-vous sûr de vouloir réinitialiser la base de données des nœuds ?", ""
confirm.shutdown, "Êtes-vous sûr de vouloir éteindre ?", ""
confirm.factory_reset, "Êtes-vous sûr de vouloir effectuer une réinitialisation d'usine ?", ""
confirm.factory_reset_config, "Êtes-vous sûr de vouloir réinitialiser la configuration d'usine ?", ""
confirm.save_before_exit_section, "Vous avez des modifications non enregistrées dans {section}. Enregistrer avant de quitter ?", ""
prompt.select_region, "Sélectionnez votre région :", ""
dialog.slow_down_title, "Ralentissez", ""
dialog.slow_down_body, "Veuillez attendre 2 secondes entre les messages.", ""
dialog.node_details_title, "📡 Détails du nœud : {name}", ""
dialog.traceroute_not_sent_title, "Traceroute non envoyé", ""
dialog.traceroute_not_sent_body, "Veuillez attendre {seconds} secondes avant d'envoyer un autre traceroute.", ""
dialog.traceroute_sent_title, "Traceroute envoyé à : {name}", ""
dialog.traceroute_sent_body, "Les résultats apparaîtront dans la fenêtre des messages.", ""
dialog.help_title, "Aide - Raccourcis clavier", ""
help.scroll, "Haut/Bas = Défilement", ""
help.switch_window, "Gauche/Droite = Changer de fenêtre", ""
help.jump_windows, "F1/F2/F3 = Aller à Canal/Messages/Nœuds", ""
help.enter, "ENTRÉE = Envoyer / Sélectionner", ""
help.settings, "` ou F12 = Paramètres", ""
help.quit, "ESC = Quitter", ""
help.packet_log, "Ctrl+P = Activer/désactiver le journal des paquets", ""
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", ""
longName, "Nom long du nœud", "Si vous êtes un opérateur radioamateur agréé et avez activé le mode HAM, cela doit être votre indicatif."
shortName, "Nom court du nœud", "Doit contenir jusqu'à 4 octets."
isLicensed, "Activer le mode radioamateur (HAM)", "IMPORTANT : lire la documentation Meshtastic avant d'activer."
[app_settings]
title, "Paramètres de l'application", ""
channel_list_16ths, "Largeur de la liste des canaux", ""
node_list_16ths, "Largeur de la liste des nœuds", ""
single_pane_mode, "Mode panneau unique", ""
db_file_path, "Chemin du fichier de base de données", ""
log_file_path, "Chemin du fichier journal", ""
node_configs_file_path, "Chemin des configurations des nœuds", ""
language, "Langue", ""
message_prefix, "Préfixe des messages", ""
sent_message_prefix, "Préfixe des messages envoyés", ""
notification_symbol, "Symbole de notification", ""
notification_sound, "Son de notification", ""
ack_implicit_str, "ACK (implicite)", ""
ack_str, "ACK", ""
nak_str, "NAK", ""
ack_unknown_str, "ACK (inconnu)", ""
node_sort, "Tri des nœuds", ""
theme, "Thème", ""
ping_bot, "Bot Ping", ""
[app_settings.ping_bot]
title, "Bot Ping", ""
catch_words, "Mots déclencheurs", "Mots déclencheurs du bot séparés par des points-virgules."
response_word, "Mot de réponse", "Mot de réponse du bot."
[config.device]
title, "Appareil", ""
role, "Rôle", ""
serial_enabled, "Activer la console série", ""
button_gpio, "GPIO bouton", ""
buzzer_gpio, "GPIO buzzer", ""
rebroadcast_mode, "Mode de rediffusion", ""
node_info_broadcast_secs, "Intervalle diffusion infos nœud", ""
double_tap_as_button_press, "Double tap = bouton", ""
is_managed, "Activer mode géré", ""
disable_triple_click, "Désactiver triple clic", ""
tzdef, "Fuseau horaire", ""
led_heartbeat_disabled, "Désactiver LED heartbeat", ""
buzzer_mode, "Mode buzzer", ""
[config.network]
title, "Réseau", ""
wifi_enabled, "Wi-Fi activé", ""
wifi_ssid, "SSID Wi-Fi", ""
wifi_psk, "Mot de passe Wi-Fi", ""
ntp_server, "Serveur NTP", ""
eth_enabled, "Ethernet activé", ""
address_mode, "Mode IPv4", ""
ip, "Adresse IP", ""
gateway, "Passerelle", ""
subnet, "Sous-réseau", ""
dns, "DNS", ""
[config.display]
title, "Affichage", ""
screen_on_secs, "Durée écran actif", ""
gps_format, "Format GPS", ""
auto_screen_carousel_secs, "Intervalle carrousel", ""
compass_north_top, "Toujours nord en haut", ""
flip_screen, "Retourner écran", ""
units, "Unités préférées", ""
[config.bluetooth]
title, "Bluetooth", ""
enabled, "Activé", ""
mode, "Mode d'appairage", ""
fixed_pin, "Code PIN fixe", ""
[module.mqtt]
title, "MQTT", ""
enabled, "Activé", ""
address, "Adresse serveur", ""
username, "Nom d'utilisateur", ""
password, "Mot de passe", ""
encryption_enabled, "Chiffrement activé", ""
json_enabled, "JSON activé", ""
tls_enabled, "TLS activé", ""
[module.serial]
title, "Série", ""
enabled, "Activé", ""
echo, "Écho", ""
rxd, "GPIO réception", ""
txd, "GPIO transmission", ""
baud, "Débit en bauds", ""
timeout, "Délai", ""
[module.telemetry]
title, "Télémétrie", ""
device_update_interval, "Intervalle métriques appareil", ""
environment_update_interval, "Intervalle métriques environnement", ""
environment_measurement_enabled, "Télémétrie environnement activée", ""
[module.audio]
title, "Audio", ""
codec2_enabled, "Activé", ""
ptt_pin, "GPIO PTT", ""
bitrate, "Débit audio", ""
[module.remote_hardware]
title, "Matériel distant", ""
enabled, "Activé", ""
available_pins, "Broches disponibles", ""
[module.ambient_lighting]
title, "Éclairage ambiant", ""
led_state, "État LED", ""
current, "Courant", ""
red, "Rouge", ""
green, "Vert", ""
blue, "Bleu", ""
+12
View File
@@ -78,6 +78,7 @@ help.traceroute, "Ctrl+T или F4 = Traceroute", ""
help.node_info, "F5 = Полная информация об узле", ""
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
help.favorite, "Ctrl+F = Избранное", ""
help.bot_responder, "Ctrl+B = Вкл/выкл автоответчик", ""
help.ignore, "Ctrl+G = Игнорировать", ""
help.search, "Ctrl+/ или / = Поиск", ""
help.help, "Ctrl+K = Справка", ""
@@ -90,6 +91,11 @@ confirm.remove_ignored, "Убрать {name} из игнорируемых?", ""
confirm.region_unset, "Ваш регион НЕ ЗАДАН. Установить сейчас?", ""
dialog.resize_title, "Увеличьте окно", ""
dialog.resize_body, "Пожалуйста, увеличьте окно до {rows} строк.", ""
bot.status.enabled, "Включен", ""
bot.status.disabled, "Выключен", ""
bot.dialog.title, "Автоответчик", ""
bot.dialog.body, "Автоответчик теперь {status}.", ""
bot.status.message, "Автоответчик теперь {status}.", ""
[User Settings]
user, "Пользователь"
@@ -116,10 +122,16 @@ nak_str, "NAK", ""
ack_unknown_str, "ACK (неизвестный)", ""
node_sort, "Сортировка нод", ""
theme, "Тема", ""
ping_bot, "Пинг-бот", ""
COLOR_CONFIG_DARK, "Цвета темы (темная)", ""
COLOR_CONFIG_LIGHT, "Цвета темы (светлая)", ""
COLOR_CONFIG_GREEN, "Цвета темы (зеленая)", ""
[app_settings.ping_bot]
title, "Пинг-бот", ""
catch_words, "Слова-триггеры", "Слова для активации бота, разделенные точкой с запятой."
response_word, "Ответное слово", "Ответное слово бота."
[app_settings.color_config]
default, "По умолчанию", ""
background, "Фон", ""
+87
View File
@@ -0,0 +1,87 @@
# A basic auto-responder bot that replies to specific messages when bot mode is enabled.
import logging
import threading
import time
from typing import Any, Dict
import contact.ui.default_config as config
from contact.utilities.singleton import app_state, interface_state, ui_state
from contact.message_handlers.tx_handler import send_message
BOT_RESPONSE_DELAY_SECONDS = 2.3
def _get_bot_catch_words() -> set[str]:
"""Return normalized bot trigger words from app settings."""
raw_words = getattr(config, "ping_bot_catch_words", "ping; test")
words = {
word.strip().casefold()
for word in raw_words.replace(";", ",").split(",")
if word.strip()
}
return words or {"ping"}
def is_bot_message(message: str) -> bool:
"""Return True when the incoming message should trigger an automatic response."""
return message.strip().casefold() in _get_bot_catch_words()
def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool:
"""Send a basic response when bot mode is enabled."""
if not ui_state.bot_mode_enabled:
return False
if not is_bot_message(message):
""" Only respond to specific messages. """
return False
from_node = packet.get("from")
if from_node is None:
return False
if from_node == interface_state.myNodeNum:
return False
snr = packet.get('rxSnr', -128)
rssi = packet.get('rxRssi', -128)
replyIDset = (packet.get('decoded') or {}).get('replyId', False)
hop_start = packet.get('hopStart', 0)
hop_limit = packet.get('hopLimit', 0)
transport_type = packet.get('transportMechanism', None)
hops = hop_start - hop_limit
details = []
if snr != -128:
details.append(f"SNR: {snr}")
if rssi != -128:
details.append(f"RSSI: {rssi}")
if hops != 0:
details.append(f"Hops: {hops}")
if replyIDset:
details.append(f"Relay: {replyIDset}")
transport_text = str(transport_type).upper() if transport_type is not None else ""
for transport_name in ("UDP", "MQTT"):
if transport_name in transport_text:
details.append(f"Via: {transport_name}")
response_data_string = getattr(config, "ping_bot_response_word", "Pong!")
if details:
response_data_string += f" {', '.join(details)}"
def send_response_delayed() -> None:
try:
time.sleep(BOT_RESPONSE_DELAY_SECONDS)
with app_state.lock:
if not ui_state.bot_mode_enabled:
return
send_message(response_data_string,channel=send_channel)
# Import locally to avoid circular import at module import time.
from contact.ui.contact_ui import request_ui_redraw
request_ui_redraw(channels=True, messages=True, scroll_messages_to_bottom=True)
logging.info("Bot response sent to %s on channel index %s", from_node, send_channel)
except Exception:
logging.exception("Bot response send failed for destination %s", from_node)
threading.Thread(target=send_response_delayed, name="bot-response", daemon=True).start()
return True
+25 -3
View File
@@ -46,6 +46,7 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
from contact.utilities.utils import (
refresh_node_list,
add_new_message,
get_reply_context,
)
from contact.ui.contact_ui import (
add_notification,
@@ -60,6 +61,7 @@ from contact.utilities.db_handler import (
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state
from contact.message_handlers.bot_handler import bot_respond
def play_sound():
@@ -168,6 +170,8 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
channel_number = ui_state.channel_list.index(packet["from"])
bot_respond(packet, message_string, channel_number)
channel_id = ui_state.channel_list[channel_number]
if channel_id != ui_state.channel_list[ui_state.selected_channel]:
@@ -180,14 +184,32 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
message_from_id = packet["from"]
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string)
# replyId is a field of Meshtastic's decrypted Data payload.
reply_id = packet["decoded"].get("replyId")
reply_context = get_reply_context(reply_id) if reply_id is not None else ""
add_new_message(
channel_id,
f"{config.message_prefix} [{hops}] {message_from_string} ",
f"{reply_context}{message_string}",
packet_id=packet.get("id"),
)
if refresh_channels:
request_ui_redraw(channels=True)
if refresh_messages:
request_ui_redraw(messages=True, scroll_messages_to_bottom=True)
request_ui_redraw(
messages=True,
scroll_messages_to_bottom=True,
preserve_message_selection=(ui_state.current_window == 1),
)
save_message_to_db(channel_id, message_from_id, message_string)
save_message_to_db(
channel_id,
message_from_id,
message_string,
packet_id=packet.get("id"),
reply_id=reply_id,
)
except KeyError as e:
logging.error(f"Error processing packet: {e}")
+27 -12
View File
@@ -56,7 +56,7 @@ def onAckNak(packet: Dict[str, Any]) -> None:
message,
)
update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type)
update_ack_nak(acknak["channel"], acknak["timestamp"], acknak["dbMessage"], ack_type)
channel_number = ui_state.channel_list.index(acknak["channel"])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
@@ -164,7 +164,13 @@ def on_response_traceroute(packet: Dict[str, Any]) -> None:
save_message_to_db(channel_id, packet["from"], msg_str)
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
def send_message(
message: str,
destination: int = BROADCAST_NUM,
channel: int = 0,
reply_id: int | None = None,
reply_context: str = "",
) -> None:
"""
Sends a chat message using the selected channel.
"""
@@ -177,23 +183,32 @@ def send_message(message: str, destination: int = BROADCAST_NUM, channel: int =
elif isinstance(channel_id, str):
send_on_channel = channel
sent_message_data = interface_state.interface.sendText(
text=message,
destinationId=destination,
wantAck=True,
wantResponse=False,
onResponse=onAckNak,
channelIndex=send_on_channel,
send_kwargs = {
"text": message,
"destinationId": destination,
"wantAck": True,
"wantResponse": False,
"onResponse": onAckNak,
"channelIndex": send_on_channel,
}
if reply_id is not None:
send_kwargs["replyId"] = reply_id
sent_message_data = interface_state.interface.sendText(**send_kwargs)
add_new_message(
channel_id,
config.sent_message_prefix + config.ack_unknown_str + ": ",
f"{reply_context}{message}",
packet_id=sent_message_data.id,
)
add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message)
timestamp = save_message_to_db(channel_id, myid, message)
timestamp = save_message_to_db(channel_id, myid, message, packet_id=sent_message_data.id, reply_id=reply_id)
ack_naks[sent_message_data.id] = {
"channel": channel_id,
"messageIndex": len(ui_state.all_messages[channel_id]) - 1,
"timestamp": timestamp,
"dbMessage": message,
}
+330 -102
View File
@@ -2,14 +2,22 @@ import curses
import logging
import time
import traceback
from numbers import Real
from typing import Union
from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list
from contact.utilities.utils import (
get_channels,
get_readable_duration,
get_time_ago,
refresh_node_list,
add_new_message,
build_reply_prefix,
)
from contact.settings import settings_menu
from contact.message_handlers.tx_handler import send_message, send_traceroute
from contact.utilities.utils import parse_protobuf
from contact.ui.colors import get_color
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived, load_older_messages
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.utilities.emoji_utils import normalize_message_text
@@ -40,6 +48,7 @@ def request_ui_redraw(
packetlog: bool = False,
full: bool = False,
scroll_messages_to_bottom: bool = False,
preserve_message_selection: bool = False,
) -> None:
ui_state.redraw_channels = ui_state.redraw_channels or channels
ui_state.redraw_messages = ui_state.redraw_messages or messages
@@ -47,6 +56,7 @@ def request_ui_redraw(
ui_state.redraw_packetlog = ui_state.redraw_packetlog or packetlog
ui_state.redraw_full_ui = ui_state.redraw_full_ui or full
ui_state.scroll_messages_to_bottom = ui_state.scroll_messages_to_bottom or scroll_messages_to_bottom
ui_state.preserve_message_selection = ui_state.preserve_message_selection or preserve_message_selection
def process_pending_ui_updates(stdscr: curses.window) -> None:
@@ -57,6 +67,7 @@ def process_pending_ui_updates(stdscr: curses.window) -> None:
ui_state.redraw_nodes = False
ui_state.redraw_packetlog = False
ui_state.scroll_messages_to_bottom = False
ui_state.preserve_message_selection = False
handle_resize(stdscr, False)
return
@@ -70,9 +81,14 @@ def process_pending_ui_updates(stdscr: curses.window) -> None:
if ui_state.redraw_messages:
scroll_to_bottom = ui_state.scroll_messages_to_bottom
preserve_selection = ui_state.preserve_message_selection
ui_state.redraw_messages = False
ui_state.scroll_messages_to_bottom = False
draw_messages_window(scroll_to_bottom)
ui_state.preserve_message_selection = False
if preserve_selection:
draw_messages_window(scroll_to_bottom, preserve_selection=True)
else:
draw_messages_window(scroll_to_bottom)
if ui_state.redraw_packetlog:
ui_state.redraw_packetlog = False
@@ -369,7 +385,8 @@ def main_ui(stdscr: curses.window) -> None:
while True:
with app_state.lock:
process_pending_ui_updates(stdscr)
draw_text_field(entry_win, f"Message: {(input_text or '')[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
entry_display = f"{ui_state.reply_context}{input_text or ''}"
draw_text_field(entry_win, f"Message: {entry_display[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
# Get user input from entry window
try:
@@ -425,6 +442,12 @@ def main_ui(stdscr: curses.window) -> None:
elif char == chr(16): # Ctrl + P for Packet Log
handle_ctrl_p()
elif char == chr(18): # Ctrl + R for Reply
cancelling_reply = bool(ui_state.reply_context)
input_text = handle_ctrl_r(input_text)
if cancelling_reply:
entry_win.erase()
elif char == curses.KEY_RESIZE:
input_text = ""
queued_char = drain_resize_events(entry_win)
@@ -445,6 +468,9 @@ def main_ui(stdscr: curses.window) -> None:
elif char == chr(6): # Ctrl + F to toggle favorite
handle_ctrl_f(stdscr)
elif char == chr(2): # Ctrl + B to toggle bot responder
handle_ctrl_b(stdscr)
elif char == chr(7): # Ctrl + G to toggle ignored
handle_ctlr_g(stdscr)
@@ -484,7 +510,8 @@ def handle_home() -> None:
if ui_state.current_window == 0:
select_channel(0)
elif ui_state.current_window == 1:
ui_state.selected_message = 0
set_message_selection(0)
refresh_message_highlight()
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(0)
@@ -497,8 +524,8 @@ def handle_end() -> None:
if ui_state.current_window == 0:
select_channel(len(ui_state.channel_list) - 1)
elif ui_state.current_window == 1:
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = max(msg_line_count - get_msg_window_lines(messages_win, packetlog_win), 0)
set_message_selection(messages_pad.getmaxyx()[0] - 1)
refresh_message_highlight()
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(len(ui_state.node_list) - 1)
@@ -510,9 +537,8 @@ def handle_pageup() -> None:
if ui_state.current_window == 0:
select_channel(ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2))
elif ui_state.current_window == 1:
ui_state.selected_message = max(
ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0
)
set_message_selection(ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win))
refresh_message_highlight()
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2))
@@ -524,11 +550,8 @@ def handle_pagedown() -> None:
if ui_state.current_window == 0:
select_channel(ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2))
elif ui_state.current_window == 1:
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = min(
ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win),
msg_line_count - get_msg_window_lines(messages_win, packetlog_win),
)
set_message_selection(ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win))
refresh_message_highlight()
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2))
@@ -551,6 +574,9 @@ def handle_leftright(char: int) -> None:
refresh_main_window(ui_state.current_window, selected=True)
draw_window_arrows(ui_state.current_window)
if ui_state.current_window == 1:
draw_messages_window(True)
refresh_message_highlight()
def handle_function_keys(char: int) -> None:
@@ -581,6 +607,9 @@ def handle_function_keys(char: int) -> None:
refresh_main_window(ui_state.current_window, selected=True)
draw_window_arrows(ui_state.current_window)
if ui_state.current_window == 1:
draw_messages_window(True)
refresh_message_highlight()
def handle_enter(input_text: str) -> str:
@@ -608,6 +637,15 @@ def handle_enter(input_text: str) -> str:
return input_text
elif len(input_text) > 0:
if ui_state.reply_context and ui_state.reply_id is None:
contact.ui.dialog.dialog(
t("ui.dialog.reply_unavailable_title", default="Reply unavailable"),
t(
"ui.dialog.reply_unavailable_body",
default="This message has no packet ID, so Contact cannot send a native Meshtastic reply.",
),
)
return input_text
# TODO: This is a hack to prevent sending messages too quickly. Let's get errors from the node.
now = time.monotonic()
if now - ui_state.last_sent_time < 2.5:
@@ -617,7 +655,15 @@ def handle_enter(input_text: str) -> str:
)
return input_text
# Enter key pressed, send user input as message
send_message(input_text, channel=ui_state.selected_channel)
send_message(
input_text,
channel=ui_state.selected_channel,
reply_id=ui_state.reply_id,
reply_context=ui_state.reply_context,
)
ui_state.reply_id = None
ui_state.reply_context = ""
ui_state.reply_id_unavailable = False
draw_messages_window(True)
ui_state.last_sent_time = now
entry_win.erase()
@@ -656,24 +702,27 @@ def handle_f5_key(stdscr: curses.window) -> None:
if has_coords:
message_parts.append(f"https://maps.google.com/?q={pos['latitude']:.4f},{pos['longitude']:.4f}")
if any(key in node for key in ["snr", "hopsAway", "lastHeard"]):
if any(node.get(key) is not None for key in ["snr", "hopsAway", "lastHeard"]):
message_parts.append("")
message_parts.append("**🌐 Network Metrics:**")
if "snr" in node:
if node.get("snr") is not None:
snr = node["snr"]
snr_status = (
"🟢 Excellent"
if snr > 10
else (
"🟡 Good"
if snr > 3
else "🟠 Fair" if snr > -10 else "🔴 Poor" if snr > -20 else "💀 Very Poor"
if isinstance(snr, Real):
snr_status = (
"🟢 Excellent"
if snr > 10
else (
"🟡 Good"
if snr > 3
else "🟠 Fair" if snr > -10 else "🔴 Poor" if snr > -20 else "💀 Very Poor"
)
)
)
message_parts.append(f"• SNR: {snr}dB {snr_status}")
message_parts.append(f"• SNR: {snr}dB {snr_status}")
else:
message_parts.append(f"• SNR: {snr}dB")
if "hopsAway" in node:
if node.get("hopsAway") is not None:
hops = node["hopsAway"]
hop_emoji = "📡" if hops == 0 else "🔄" if hops == 1 else "⏩"
message_parts.append(f"• Hops away: {hop_emoji} {hops}")
@@ -686,24 +735,32 @@ def handle_f5_key(stdscr: curses.window) -> None:
message_parts.append("")
message_parts.append("**📊 Device Metrics:**")
if "batteryLevel" in metrics:
if metrics.get("batteryLevel") is not None:
battery = metrics["batteryLevel"]
battery_emoji = "🟢" if battery > 50 else "🟡" if battery > 20 else "🔴"
battery_emoji = "🔴"
if isinstance(battery, Real):
battery_emoji = "🟢" if battery > 50 else "🟡" if battery > 20 else "🔴"
voltage_info = f" ({metrics['voltage']}v)" if "voltage" in metrics else ""
message_parts.append(f"• Battery: {battery_emoji} {battery}%{voltage_info}")
if "uptimeSeconds" in metrics:
if metrics.get("uptimeSeconds") is not None:
message_parts.append(f"• Uptime: ⏱️ {get_readable_duration(metrics['uptimeSeconds'])}")
if "channelUtilization" in metrics:
if metrics.get("channelUtilization") is not None:
util = metrics["channelUtilization"]
util_emoji = "🔴" if util > 80 else "🟡" if util > 50 else "🟢"
message_parts.append(f"• Channel utilization: {util_emoji} {util:.2f}%")
if isinstance(util, Real):
util_emoji = "🔴" if util > 80 else "🟡" if util > 50 else "🟢"
message_parts.append(f"• Channel utilization: {util_emoji} {util:.2f}%")
else:
message_parts.append(f"• Channel utilization: {util}%")
if "airUtilTx" in metrics:
if metrics.get("airUtilTx") is not None:
air_util = metrics["airUtilTx"]
air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢"
message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%")
if isinstance(air_util, Real):
air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢"
message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%")
else:
message_parts.append(f"• Air utilization TX: {air_util}%")
title = t(
"ui.dialog.node_details_title",
@@ -789,7 +846,10 @@ def handle_f5_key(stdscr: curses.window) -> None:
curses.doupdate()
dialog_win.timeout(200)
char = dialog_win.getch()
try:
char = dialog_win.getch()
except curses.error:
continue
if menu_state.need_redraw:
menu_state.need_redraw = False
@@ -818,7 +878,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
elif char == curses.KEY_RESIZE:
continue
except KeyError:
except (IndexError, KeyError):
return
finally:
if dialog_win is not None:
@@ -902,6 +962,49 @@ def handle_ctrl_p() -> None:
draw_messages_window(True)
def get_message_at_display_line(channel, display_line: int):
"""Return the message containing a rendered line in a channel, if any."""
line = 0
for prefix, message in ui_state.all_messages.get(channel, []):
wrapped_lines = wrap_text(
normalize_message_text(f"{prefix}{message}"),
messages_win.getmaxyx()[1] - 2,
)
if line <= display_line < line + len(wrapped_lines):
return None if prefix.startswith("--") else (prefix, message)
line += len(wrapped_lines)
return None
def handle_ctrl_r(input_text: str) -> str:
"""Toggle a reply to the message at the current Messages-pane position."""
if ui_state.reply_context:
ui_state.reply_id = None
ui_state.reply_context = ""
ui_state.reply_id_unavailable = False
return ""
if ui_state.current_window != 1 or not ui_state.channel_list:
return input_text
channel = ui_state.channel_list[ui_state.selected_channel]
selected = get_message_at_display_line(channel, ui_state.selected_message)
if selected is None:
return input_text
message_index = next(
index
for index, entry in enumerate(ui_state.all_messages[channel])
if entry == selected
)
packet_ids = ui_state.message_packet_ids.get(channel, [])
prefix, message = selected
ui_state.reply_context = build_reply_prefix(prefix, message)
ui_state.reply_id = packet_ids[message_index] if message_index < len(packet_ids) else None
ui_state.reply_id_unavailable = ui_state.reply_id is None
return input_text
# --- Ctrl+K handler for Help ---
def handle_ctrl_k(stdscr: curses.window) -> None:
"""Handle Ctrl + K to show a help window with shortcut keys."""
@@ -915,10 +1018,12 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
t("ui.help.settings", default="` or F12 = Settings"),
t("ui.help.quit", default="ESC = Quit"),
t("ui.help.packet_log", default="Ctrl+P = Toggle Packet Log"),
t("ui.help.reply", default="Ctrl+R = Reply to message at cursor"),
t("ui.help.traceroute", default="Ctrl+T or F4 = Traceroute"),
t("ui.help.node_info", default="F5 = Full node info"),
t("ui.help.archive_chat", default="Ctrl+D = Archive chat / remove node"),
t("ui.help.favorite", default="Ctrl+F = Favorite"),
t("ui.help.bot_responder", default="Ctrl+B = Toggle Bot Responder"),
t("ui.help.ignore", default="Ctrl+G = Ignore"),
t("ui.help.search", default="Ctrl+/ = Search"),
t("ui.help.help", default="Ctrl+K = Help"),
@@ -930,6 +1035,32 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
handle_resize(stdscr, False)
def handle_ctrl_b(stdscr: curses.window) -> None:
"""Handle Ctrl + B key events to toggle automatic bot responses."""
ui_state.bot_mode_enabled = not ui_state.bot_mode_enabled
status = t("ui.bot.status.enabled", default="Enabled") if ui_state.bot_mode_enabled else t(
"ui.bot.status.disabled", default="Disabled"
)
curses.curs_set(0)
contact.ui.dialog.dialog(
t("ui.bot.dialog.title", default="Bot Responder"),
t("ui.bot.dialog.body", default="Bot responder is now {status}.", status=status),
)
if ui_state.channel_list:
channel_id = ui_state.channel_list[ui_state.selected_channel]
add_new_message(
channel_id,
f"{config.message_prefix} Info: ",
t("ui.bot.status.message", default="Bot responder is now {status}.", status=status.lower()),
)
draw_messages_window(True)
curses.curs_set(1)
handle_resize(stdscr, False)
def handle_ctrl_d() -> None:
if ui_state.current_window == 0:
if isinstance(ui_state.channel_list[ui_state.selected_channel], int):
@@ -1107,7 +1238,7 @@ def draw_channel_list() -> None:
channel_win.refresh()
def draw_messages_window(scroll_to_bottom: bool = False) -> None:
def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: bool = False) -> None:
"""Update the messages window based on the selected channel and scroll position."""
if ui_state.current_window != 1 and ui_state.single_pane_mode:
@@ -1117,18 +1248,15 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
channel = ui_state.channel_list[ui_state.selected_channel]
msg_line_count = 0
message_ranges = []
if channel in ui_state.all_messages:
messages = ui_state.all_messages[channel]
msg_line_count = 0
row = 0
rendered_lines = []
for prefix, message in messages:
start_line = len(rendered_lines)
full_message = normalize_message_text(f"{prefix}{message}")
wrapped_lines = wrap_text(full_message, messages_win.getmaxyx()[1] - 2)
msg_line_count += len(wrapped_lines)
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
for line in wrapped_lines:
if prefix.startswith("--"):
color = get_color("timestamps")
@@ -1137,18 +1265,31 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
else:
color = get_color("rx_messages")
messages_pad.addstr(row, 1, line, color)
row += 1
rendered_lines.append((line, color))
if not prefix.startswith("--"):
message_ranges.append((start_line, len(rendered_lines), color))
msg_line_count = len(rendered_lines)
messages_pad.resize(max(1, msg_line_count), messages_win.getmaxyx()[1])
for row, (line, color) in enumerate(rendered_lines):
messages_pad.addstr(row, 1, line, color)
ui_state.message_line_ranges[channel] = message_ranges
paint_frame(messages_win, selected=(ui_state.current_window == 1))
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
if scroll_to_bottom:
ui_state.selected_message = max(msg_line_count - visible_lines, 0)
ui_state.start_index[1] = max(msg_line_count - visible_lines, 0)
if preserve_selection:
set_message_selection(ui_state.selected_message)
ui_state.start_index[1] = max(0, msg_line_count - visible_lines)
else:
set_message_selection(msg_line_count - 1)
else:
ui_state.selected_message = max(min(ui_state.selected_message, msg_line_count - visible_lines), 0)
set_message_selection(ui_state.selected_message)
refresh_message_highlight()
messages_win.refresh()
refresh_pad(1)
@@ -1159,6 +1300,39 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
menu_state.need_redraw = True
def refresh_message_highlight() -> None:
"""Apply a reverse-video highlight to the message at the current scroll position."""
previous_range = ui_state.highlighted_message_range
width = max(0, messages_win.getmaxyx()[1] - 2)
if not ui_state.channel_list or ui_state.current_window != 1:
if previous_range:
start, end, color = previous_range
for row in range(start, end):
messages_pad.chgat(row, 1, width, color)
ui_state.highlighted_message_range = ()
return
channel = ui_state.channel_list[ui_state.selected_channel]
ranges = ui_state.message_line_ranges.get(channel, [])
selected_range = next(
(line_range for line_range in ranges if line_range[0] <= ui_state.selected_message < line_range[1]),
None,
)
if previous_range and previous_range != selected_range:
start, end, color = previous_range
for row in range(start, end):
messages_pad.chgat(row, 1, width, color)
if selected_range:
start, end, color = selected_range
for row in range(start, end):
messages_pad.chgat(row, 1, width, color | curses.A_REVERSE)
ui_state.highlighted_message_range = selected_range or ()
def draw_node_list() -> None:
"""Update the nodes list window and pad based on the current state."""
global nodes_pad
@@ -1238,31 +1412,78 @@ def scroll_channels(direction: int) -> None:
def scroll_messages(direction: int) -> None:
"""Scroll through the messages in the current channel by a given direction."""
ui_state.selected_message += direction
if direction < 0 and ui_state.selected_message == 0 and ui_state.channel_list:
channel = ui_state.channel_list[ui_state.selected_channel]
previous_height = messages_pad.getmaxyx()[0]
if load_older_messages(channel):
draw_messages_window()
added_height = max(0, messages_pad.getmaxyx()[0] - previous_height)
ui_state.selected_message = added_height
ui_state.start_index[1] = added_height
refresh_pad(1)
return
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = max(
0, min(ui_state.selected_message, msg_line_count - get_msg_window_lines(messages_win, packetlog_win))
)
max_index = msg_line_count - 1
visible_height = get_msg_window_lines(messages_win, packetlog_win)
if ui_state.selected_message < ui_state.start_index[ui_state.current_window]: # Moving above the visible area
ui_state.start_index[ui_state.current_window] = ui_state.selected_message
elif ui_state.selected_message >= ui_state.start_index[ui_state.current_window]: # Moving below the visible area
ui_state.start_index[ui_state.current_window] = ui_state.selected_message
# Ensure start_index is within bounds
ui_state.start_index[ui_state.current_window] = max(
0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1)
)
move_message_selection(direction)
refresh_message_highlight()
messages_win.refresh()
refresh_pad(1)
draw_window_arrows(ui_state.current_window)
def set_message_selection(line: int) -> None:
"""Select a rendered message line and keep it visible without limiting selection to the viewport."""
msg_line_count = messages_pad.getmaxyx()[0]
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
ui_state.selected_message = max(0, min(line, max(msg_line_count - 1, 0)))
max_start = max(msg_line_count - visible_lines, 0)
start = ui_state.start_index[1]
if ui_state.selected_message < start:
start = ui_state.selected_message
elif ui_state.selected_message >= start + visible_lines:
start = ui_state.selected_message - visible_lines + 1
ui_state.start_index[1] = max(0, min(start, max_start))
def move_message_selection(direction: int) -> None:
"""Move between messages, keeping wrapped messages as one selectable item."""
if not ui_state.channel_list:
return
channel = ui_state.channel_list[ui_state.selected_channel]
ranges = ui_state.message_line_ranges.get(channel, [])
if not ranges:
set_message_selection(ui_state.selected_message + direction)
return
current_index = next(
(index for index, line_range in enumerate(ranges) if line_range[0] <= ui_state.selected_message < line_range[1]),
None,
)
if current_index is None:
current_index = 0 if direction > 0 else len(ranges) - 1
else:
current_index = max(0, min(current_index + direction, len(ranges) - 1))
start, end, _color = ranges[current_index]
set_message_selection(end - 1 if current_index == len(ranges) - 1 else start)
def select_last_message() -> None:
"""Select and reveal the newest message in the active channel."""
if not ui_state.channel_list:
return
channel = ui_state.channel_list[ui_state.selected_channel]
ranges = ui_state.message_line_ranges.get(channel, [])
if ranges:
_start, end, _color = ranges[-1]
set_message_selection(end - 1)
else:
set_message_selection(0)
def select_node(idx: int) -> None:
"""Select a node by index and update the UI state accordingly."""
old_selected_node = ui_state.selected_node
@@ -1355,41 +1576,48 @@ def search(win: int) -> None:
search_text = ""
entry_win.erase()
entry_win.timeout(-1)
while True:
draw_centered_text_field(entry_win, f"Search: {search_text}", 0, get_color("input"))
char = entry_win.get_wch()
if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)):
break
elif char == "\t":
start_idx = ui_state.selected_node + 1 if win == 2 else ui_state.selected_channel + 1
elif char in (curses.KEY_BACKSPACE, chr(127)):
if search_text:
search_text = search_text[:-1]
y, x = entry_win.getyx()
entry_win.move(y, x - 1)
entry_win.addch(" ") #
entry_win.move(y, x - 1)
entry_win.erase()
entry_win.refresh()
elif isinstance(char, str):
search_text += char
search_text_caseless = search_text.casefold()
l = ui_state.node_list if win == 2 else ui_state.channel_list
for i, n in enumerate(l[start_idx:] + l[:start_idx]):
if (
isinstance(n, int)
and search_text_caseless in get_name_from_database(n, "long").casefold()
or isinstance(n, int)
and search_text_caseless in get_name_from_database(n, "short").casefold()
or search_text_caseless in str(n).casefold()
):
select_func((i + start_idx) % len(l))
try:
while True:
draw_centered_text_field(entry_win, f"Search: {search_text}", 0, get_color("input"))
try:
char = entry_win.get_wch()
except curses.error:
break
if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)):
break
elif char == "\t":
start_idx = ui_state.selected_node + 1 if win == 2 else ui_state.selected_channel + 1
elif char in (curses.KEY_BACKSPACE, chr(127)):
if search_text:
search_text = search_text[:-1]
y, x = entry_win.getyx()
entry_win.move(y, x - 1)
entry_win.addch(" ") #
entry_win.move(y, x - 1)
entry_win.erase()
entry_win.refresh()
elif isinstance(char, str):
search_text += char
search_text_caseless = search_text.casefold()
l = ui_state.node_list if win == 2 else ui_state.channel_list
for i, n in enumerate(l[start_idx:] + l[:start_idx]):
if (
isinstance(n, int)
and search_text_caseless in get_name_from_database(n, "long").casefold()
or isinstance(n, int)
and search_text_caseless in get_name_from_database(n, "short").casefold()
or search_text_caseless in str(n).casefold()
):
select_func((i + start_idx) % len(l))
break
finally:
entry_win.timeout(200)
entry_win.erase()
+15 -4
View File
@@ -56,9 +56,13 @@ def _get_config_root(preferred_dir: str, fallback_name: str = ".contact_client")
return fallback_dir
# Pick the root now.
config_root = _get_config_root(parent_dir)
# Allow overriding config root via environment variable
config_root = os.getenv("CONTACT_CONFIG_ROOT")
if config_root:
if not _is_writable_dir(config_root):
raise RuntimeError(f"CONTACT_CONFIG_ROOT={config_root} is not writable")
else:
config_root = _get_config_root(parent_dir)
# Paths (derived from the chosen root)
json_file_path = os.path.join(config_root, "config.json")
@@ -238,6 +242,10 @@ def initialize_config() -> Dict[str, object]:
"ack_unknown_str": "[…]",
"node_sort": "lastHeard",
"theme": "dark",
"ping_bot": {
"catch_words": "ping; test",
"response_word": "Pong!",
},
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
"COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT,
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN,
@@ -272,7 +280,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
global node_list_16ths, channel_list_16ths, single_pane_mode
global theme, COLOR_CONFIG, language
global node_sort, notification_sound
global node_sort, notification_sound, ping_bot_catch_words, ping_bot_response_word
channel_list_16ths = loaded_config["channel_list_16ths"]
node_list_16ths = loaded_config["node_list_16ths"]
@@ -291,6 +299,9 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
ack_unknown_str = loaded_config["ack_unknown_str"]
node_sort = loaded_config["node_sort"]
theme = loaded_config["theme"]
ping_bot = loaded_config.get("ping_bot", {})
ping_bot_catch_words = ping_bot.get("catch_words", "ping; test")
ping_bot_response_word = ping_bot.get("response_word", "Pong!")
if theme == "dark":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
elif theme == "light":
+6 -7
View File
@@ -2,7 +2,7 @@ import curses
from contact.utilities.i18n import t_text
from contact.ui.colors import get_color
from contact.ui.nav_utils import draw_main_arrows
from contact.ui.nav_utils import draw_main_arrows, slice_to_width, text_width
from contact.utilities.singleton import menu_state, ui_state
@@ -19,10 +19,10 @@ def dialog(title: str, message: str) -> None:
# Parse message into lines and calculate dimensions
message_lines = message.splitlines() or [""]
max_line_length = max(len(l) for l in message_lines)
max_line_length = max(text_width(l) for l in message_lines)
# Desired size
dialog_width = max(len(title) + 4, max_line_length + 4)
dialog_width = max(text_width(title) + 4, max_line_length + 4)
desired_height = len(message_lines) + 4
# Clamp dialog size to the screen (leave a 1-cell margin if possible)
@@ -61,7 +61,7 @@ def dialog(title: str, message: str) -> None:
# Title
try:
win.addstr(0, 2, title[: max(0, dialog_width - 4)], get_color("settings_default"))
win.addstr(0, 2, slice_to_width(title, max(0, dialog_width - 4)), get_color("settings_default"))
except curses.error:
pass
@@ -80,9 +80,8 @@ def dialog(title: str, message: str) -> None:
if idx >= len(message_lines):
break
line = message_lines[idx]
# Hard-trim lines that don't fit
trimmed = line[: max(0, dialog_width - 6)]
msg_x = max(0, ((dialog_width - 2) - len(trimmed)) // 2)
trimmed = slice_to_width(line, max(0, dialog_width - 4))
msg_x = max(0, ((dialog_width - 2) - text_width(trimmed)) // 2)
try:
msg_win.addstr(1 + i, msg_x, trimmed, get_color("settings_default"))
except curses.error:
+10
View File
@@ -27,6 +27,7 @@ class ChatUIState:
current_window: int = 0
last_sent_time: float = 0.0
last_traceroute_time: float = 0.0
bot_mode_enabled: bool = False
selected_index: int = 0
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
@@ -39,6 +40,15 @@ class ChatUIState:
redraw_packetlog: bool = False
redraw_full_ui: bool = False
scroll_messages_to_bottom: bool = False
preserve_message_selection: bool = False
oldest_message_rowid: Dict[Union[str, int], int] = field(default_factory=dict)
has_older_messages: Dict[Union[str, int], bool] = field(default_factory=dict)
message_line_ranges: Dict[Union[str, int], List[tuple]] = field(default_factory=dict)
highlighted_message_range: tuple = field(default_factory=tuple)
message_packet_ids: Dict[Union[str, int], List[Any]] = field(default_factory=dict)
reply_id: Any = None
reply_context: str = ""
reply_id_unavailable: bool = False
@dataclass
+4 -1
View File
@@ -309,7 +309,10 @@ def display_menu() -> tuple[Any, Any, List[str]]:
else:
display_key = key
display_key = f"{display_key}"[: w // 2 - 2]
display_value = f"{value}"[: w // 2 - 8]
if isinstance(value, dict) or (isinstance(value, list) and len(value) != 2):
display_value = ">"
else:
display_value = f"{value}"[: w // 2 - 8]
color = get_color("settings_default", reverse=(idx == menu_state.selected_index))
menu_pad.addstr(idx, 0, f"{display_key:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
+163 -69
View File
@@ -4,13 +4,16 @@ import logging
from datetime import datetime
from typing import Optional, Union, Dict
from contact.utilities.utils import decimal_to_hex
from contact.utilities.utils import build_reply_prefix, decimal_to_hex
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
MESSAGE_PAGE_SIZE = 100
def get_table_name(channel: str) -> str:
# Construct the table name
table_name = f"{str(interface_state.myNodeNum)}_{channel}_messages"
@@ -18,7 +21,21 @@ def get_table_name(channel: str) -> str:
return quoted_table_name
def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optional[int]:
def _ensure_message_columns(db_cursor, quoted_table_name: str) -> None:
"""Add fields introduced after the original message-history schema."""
table_columns = {row[1] for row in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")}
for column, definition in (("ack_type", "TEXT"), ("packet_id", "INTEGER"), ("reply_id", "INTEGER")):
if column not in table_columns:
db_cursor.execute(f"ALTER TABLE {quoted_table_name} ADD COLUMN {column} {definition}")
def save_message_to_db(
channel: str,
user_id: str,
message_text: str,
packet_id: Optional[int] = None,
reply_id: Optional[int] = None,
) -> Optional[int]:
"""Save messages to the database, ensuring the table exists."""
try:
quoted_table_name = get_table_name(channel)
@@ -27,20 +44,25 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT
ack_type TEXT,
packet_id INTEGER,
reply_id INTEGER
"""
ensure_table_exists(quoted_table_name, schema)
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
_ensure_message_columns(db_cursor, quoted_table_name)
timestamp = int(time.time())
# Insert the message
insert_query = f"""
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type)
VALUES (?, ?, ?, ?)
INSERT INTO {quoted_table_name}
(user_id, message_text, timestamp, ack_type, packet_id, reply_id)
VALUES (?, ?, ?, ?, ?, ?)
"""
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None))
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None, packet_id, reply_id))
db_connection.commit()
return timestamp
@@ -53,7 +75,8 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None:
try:
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
update_query = f"""
UPDATE {get_table_name(channel)}
@@ -73,35 +96,100 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
logging.error(f"Unexpected error in update_ack_nak: {e}")
def load_messages_from_db() -> None:
def _format_db_messages(db_messages, node_names):
"""Format database rows and packet IDs, adding one timestamp separator per hour."""
hourly_messages = {}
known_messages = {}
for _rowid, user_id, message, timestamp, ack_type, packet_id, reply_id in db_messages:
if user_id is None or message is None or timestamp is None:
logging.warning(f"Skipping row with NULL required field(s): {(user_id, message, timestamp, ack_type)}")
continue
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
ack_str = config.ack_unknown_str
if ack_type == "Implicit":
ack_str = config.ack_implicit_str
elif ack_type == "Ack":
ack_str = config.ack_str
elif ack_type == "Nak":
ack_str = config.nak_str
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
sanitized_message = message.replace("\x00", "")
if user_id == str(interface_state.myNodeNum):
formatted_message = (f"{ts_str} {config.sent_message_prefix}{ack_str}: ", sanitized_message)
else:
try:
fallback_name = decimal_to_hex(int(user_id))
except (TypeError, ValueError):
fallback_name = str(user_id)
formatted_message = (
f"{ts_str} {config.message_prefix} {node_names.get(str(user_id), fallback_name)}: ",
sanitized_message,
)
if reply_id is not None and reply_id in known_messages:
referenced_prefix, referenced_message = known_messages[reply_id]
formatted_message = (formatted_message[0], build_reply_prefix(referenced_prefix, referenced_message) + sanitized_message)
hourly_messages.setdefault(hour, []).append((formatted_message, packet_id))
if packet_id is not None:
known_messages[packet_id] = formatted_message
formatted = []
packet_ids = []
for hour, messages in sorted(hourly_messages.items()):
formatted.append((f"-- {hour} --", ""))
packet_ids.append(None)
for formatted_message, packet_id in messages:
formatted.append(formatted_message)
packet_ids.append(packet_id)
return formatted, packet_ids
def _load_node_names(db_cursor):
table_name = f'"{interface_state.myNodeNum}_nodedb"'
try:
return {str(row[0]): row[1] for row in db_cursor.execute(f"SELECT user_id, short_name FROM {table_name}")}
except sqlite3.Error:
return {}
def load_messages_from_db(page_size: int = MESSAGE_PAGE_SIZE) -> None:
"""Load messages from the database for all channels and update ui_state.all_messages and ui_state.channel_list."""
try:
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
db_cursor.execute(query, (f"{str(interface_state.myNodeNum)}_%_messages",))
tables = [row[0] for row in db_cursor.fetchall()]
node_names = _load_node_names(db_cursor)
# Iterate through each table and fetch its messages
for table_name in tables:
quoted_table_name = (
f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces
)
table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")]
if "ack_type" not in table_columns:
update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT"
db_cursor.execute(update_table_query)
_ensure_message_columns(db_cursor, quoted_table_name)
query = f"SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}"
query = f"""
SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id
FROM {quoted_table_name}
ORDER BY rowid DESC LIMIT ?
"""
try:
# Fetch all messages from the table
db_cursor.execute(query)
db_messages = [(row[0], row[1], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples
db_cursor.execute(query, (page_size + 1,))
rows = db_cursor.fetchall()
has_older = len(rows) > page_size
db_messages = list(reversed(rows[:page_size]))
# Extract the channel name from the table name
channel = table_name.split("_")[1]
prefix = f"{interface_state.myNodeNum}_"
channel = table_name[len(prefix) : -len("_messages")]
# Convert the channel to an integer if it's numeric, otherwise keep it as a string (nodenum vs channel name)
channel = int(channel) if channel.isdigit() else channel
@@ -110,53 +198,13 @@ def load_messages_from_db() -> None:
if channel not in ui_state.channel_list and not is_chat_archived(channel):
ui_state.channel_list.append(channel)
# Ensure the channel exists in ui_state.all_messages
if channel not in ui_state.all_messages:
ui_state.all_messages[channel] = []
# Add messages to ui_state.all_messages grouped by hourly timestamp
hourly_messages = {}
for row in db_messages:
user_id, message, timestamp, ack_type = row
# Only ack_type is allowed to be None
if user_id is None or message is None or timestamp is None:
logging.warning(f"Skipping row with NULL required field(s): {row}")
continue
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
if hour not in hourly_messages:
hourly_messages[hour] = []
ack_str = config.ack_unknown_str
if ack_type == "Implicit":
ack_str = config.ack_implicit_str
elif ack_type == "Ack":
ack_str = config.ack_str
elif ack_type == "Nak":
ack_str = config.nak_str
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
if user_id == str(interface_state.myNodeNum):
sanitized_message = message.replace("\x00", "")
formatted_message = (
f"{ts_str} {config.sent_message_prefix}{ack_str}: ",
sanitized_message,
)
else:
sanitized_message = message.replace("\x00", "")
formatted_message = (
f"{ts_str} {config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
sanitized_message,
)
hourly_messages[hour].append(formatted_message)
# Flatten the hourly messages into ui_state.all_messages[channel]
for hour, messages in sorted(hourly_messages.items()):
ui_state.all_messages[channel].append((f"-- {hour} --", ""))
ui_state.all_messages[channel].extend(messages)
# Replace the channel's messages with the freshly loaded page to avoid duplicates
formatted_messages, packet_ids = _format_db_messages(db_messages, node_names)
ui_state.all_messages[channel] = formatted_messages
ui_state.message_packet_ids[channel] = packet_ids
if db_messages:
ui_state.oldest_message_rowid[channel] = db_messages[0][0]
ui_state.has_older_messages[channel] = has_older
except sqlite3.Error as e:
logging.error(f"SQLite error while loading messages from table '{table_name}': {e}")
@@ -165,6 +213,47 @@ def load_messages_from_db() -> None:
logging.error(f"SQLite error in load_messages_from_db: {e}")
def load_older_messages(channel, page_size: int = MESSAGE_PAGE_SIZE) -> int:
"""Prepend one older page for a channel and return the number of messages loaded."""
before_rowid = ui_state.oldest_message_rowid.get(channel)
if before_rowid is None or not ui_state.has_older_messages.get(channel, False):
return 0
try:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
query = f"""
SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id
FROM {get_table_name(channel)}
WHERE rowid < ? ORDER BY rowid DESC LIMIT ?
"""
db_cursor.execute(query, (before_rowid, page_size + 1))
rows = db_cursor.fetchall()
has_older = len(rows) > page_size
db_messages = list(reversed(rows[:page_size]))
if not db_messages:
ui_state.has_older_messages[channel] = False
return 0
older, older_packet_ids = _format_db_messages(db_messages, _load_node_names(db_cursor))
current = ui_state.all_messages.setdefault(channel, [])
current_packet_ids = ui_state.message_packet_ids.setdefault(channel, [])
while len(current_packet_ids) < len(current):
current_packet_ids.append(None)
# Keep the existing page's leading separator even when the older page
# falls in the same hour. Removing it makes a timestamp that the user
# is looking at jump out of view as soon as another page is loaded.
ui_state.all_messages[channel] = older + current
ui_state.message_packet_ids[channel] = older_packet_ids + current_packet_ids
ui_state.oldest_message_rowid[channel] = db_messages[0][0]
ui_state.has_older_messages[channel] = has_older
return len(db_messages)
except sqlite3.Error as e:
logging.error(f"SQLite error loading older messages for channel '{channel}': {e}")
return 0
def init_nodedb() -> None:
"""Initialize the node database and update it with nodes from the interface."""
@@ -228,7 +317,8 @@ def update_node_info_in_db(
try:
ensure_node_table_exists() # Ensure the table exists before any operation
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote in case of numeric names
@@ -236,6 +326,7 @@ def update_node_info_in_db(
if "chat_archived" not in table_columns:
update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER"
db_cursor.execute(update_table_query)
db_connection.commit()
# Fetch existing values to preserve unchanged fields
db_cursor.execute(f"SELECT * FROM {table_name} WHERE user_id = ?", (user_id,))
@@ -311,7 +402,8 @@ def ensure_node_table_exists() -> None:
def ensure_table_exists(table_name: str, schema: str) -> None:
"""Ensure the given table exists in the database."""
try:
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
create_table_query = f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})"
db_cursor.execute(create_table_query)
@@ -331,7 +423,8 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
:return: The retrieved name or the hex of the user id
"""
try:
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
# Construct table name
@@ -359,7 +452,8 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
def is_chat_archived(user_id: int) -> int:
try:
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
table_name = f"{str(interface_state.myNodeNum)}_nodedb"
nodeinfo_table = f'"{table_name}"'
+2 -1
View File
@@ -141,7 +141,8 @@ def seed_demo_messages() -> None:
ack_type TEXT
"""
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
cursor = db_connection.cursor()
for channel_name, rows in _demo_messages().items():
+31 -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,37 @@ def get_time_ago(timestamp):
return "now"
def add_new_message(channel_id, prefix, message):
REPLY_EXCERPT_LENGTH = 5
def build_reply_prefix(prefix: str, message: str) -> str:
"""Create Contact's local display marker for a native Meshtastic reply."""
sender_text = re.sub(r"^\[[^]]+\]\s*", "", prefix).strip()
sender_text = re.sub(r"^(?:>>|<<)\s*(?:\[[^]]+\]\s*)?", "", sender_text)
sender_match = re.search(r"(.+?)\s*:\s*$", sender_text)
sender = sender_match.group(1).strip() if sender_match else "me"
excerpt = " ".join(message.replace("\x00", "").split())[:REPLY_EXCERPT_LENGTH]
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 +208,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.4"
version = "1.5.9"
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"}
+31
View File
@@ -0,0 +1,31 @@
import unittest
import importlib
import sys
import types
from unittest import mock
import contact.ui.default_config as config
class BotHandlerTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
sys.modules.setdefault(
"contact.message_handlers.tx_handler",
types.SimpleNamespace(send_message=mock.Mock()),
)
cls.bot_handler = importlib.import_module("contact.message_handlers.bot_handler")
def test_is_bot_message_uses_configured_catch_words(self) -> None:
with mock.patch.object(config, "ping_bot_catch_words", "ping; test; pong"):
self.assertTrue(self.bot_handler.is_bot_message("PING"))
self.assertTrue(self.bot_handler.is_bot_message("test"))
self.assertFalse(self.bot_handler.is_bot_message("hello"))
def test_is_bot_message_ignores_empty_config_values(self) -> None:
with mock.patch.object(config, "ping_bot_catch_words", " ; ; "):
self.assertTrue(self.bot_handler.is_bot_message("ping"))
if __name__ == "__main__":
unittest.main()
+265
View File
@@ -70,6 +70,171 @@ 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_handle_ctrl_r_prefills_reply_for_message_at_cursor(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
ui_state.message_packet_ids = {"Primary": [1234]}
ui_state.selected_message = 0
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "Same to you!")
self.assertEqual(ui_state.reply_id, 1234)
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
def test_handle_ctrl_r_clears_an_existing_reply(self) -> None:
ui_state.current_window = 1
ui_state.reply_id = 1234
ui_state.reply_context = "<Re: B1G1: Good > "
ui_state.reply_id_unavailable = False
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "")
self.assertIsNone(ui_state.reply_id)
self.assertEqual(ui_state.reply_context, "")
self.assertFalse(ui_state.reply_id_unavailable)
def test_handle_ctrl_r_shows_context_when_message_id_is_unavailable(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
ui_state.selected_message = 0
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
self.assertEqual(contact_ui.handle_ctrl_r(""), "")
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
self.assertTrue(ui_state.reply_id_unavailable)
def test_refresh_message_highlight_marks_selected_message(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.message_line_ranges = {"Primary": [(0, 2, 10), (2, 3, 20)]}
ui_state.selected_message = 2
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.refresh_message_highlight()
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20 | contact_ui.curses.A_REVERSE)
def test_refresh_message_highlight_clears_when_messages_pane_is_not_active(self) -> None:
ui_state.current_window = 0
ui_state.highlighted_message_range = (2, 3, 20)
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.refresh_message_highlight()
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20)
self.assertEqual(ui_state.highlighted_message_range, ())
def test_handle_end_moves_messages_viewport_to_bottom(self) -> None:
ui_state.current_window = 1
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
with mock.patch.object(contact_ui, "refresh_message_highlight"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.handle_end()
self.assertEqual(ui_state.selected_message, 99)
self.assertGreater(ui_state.start_index[1], 0)
def test_message_selection_can_reach_last_rendered_line(self) -> None:
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
contact_ui.set_message_selection(99)
self.assertEqual(ui_state.selected_message, 99)
self.assertEqual(ui_state.start_index[1], 90)
def test_switching_to_messages_redraws_at_bottom(self) -> None:
ui_state.current_window = 0
ui_state.single_pane_mode = False
with mock.patch.object(contact_ui, "refresh_main_window"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "refresh_message_highlight"):
contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT)
draw_messages_window.assert_called_once_with(True)
def test_move_message_selection_skips_all_lines_of_wrapped_message(self) -> None:
ui_state.channel_list = ["Primary"]
ui_state.message_line_ranges = {"Primary": [(0, 3, 10), (3, 4, 20), (4, 6, 30)]}
ui_state.selected_message = 1
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (6, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
contact_ui.move_message_selection(1)
self.assertEqual(ui_state.selected_message, 3)
contact_ui.move_message_selection(1)
self.assertEqual(ui_state.selected_message, 5)
contact_ui.move_message_selection(-1)
self.assertEqual(ui_state.selected_message, 3)
def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101, 202]
ui_state.selected_node = 1
@@ -200,3 +365,103 @@ class ContactUiTests(unittest.TestCase):
contact_ui.refresh_pad(1)
contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)
def test_search_ignores_no_input_from_curses(self) -> None:
ui_state.node_list = [101]
ui_state.selected_node = 0
contact_ui.entry_win = mock.Mock()
contact_ui.entry_win.get_wch.side_effect = contact_ui.curses.error("no input")
with mock.patch.object(contact_ui, "draw_centered_text_field"):
with mock.patch.object(contact_ui, "get_color", return_value=0):
contact_ui.search(2)
contact_ui.entry_win.timeout.assert_has_calls([mock.call(-1), mock.call(200)])
contact_ui.entry_win.erase.assert_called()
def test_f5_node_details_ignores_no_input_from_curses(self) -> None:
stdscr = mock.Mock()
ui_state.node_list = [101]
ui_state.selected_node = 0
ui_state.current_window = 2
dialog_win = mock.Mock()
dialog_win.getch.side_effect = [contact_ui.curses.error("no input"), 27]
msg_win = mock.Mock()
dialog_win.derwin.return_value = msg_win
interface = mock.Mock()
interface.nodesByNum = {
101: {
"num": 101,
"user": {
"longName": "Test Node",
"shortName": "TN",
"hwModel": "T-Beam",
"role": "CLIENT",
"publicKey": "abc",
},
}
}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui.curses, "LINES", 24, create=True):
with mock.patch.object(contact_ui.curses, "COLS", 80, create=True):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui.curses, "update_lines_cols"):
with mock.patch.object(contact_ui.curses, "doupdate"):
with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_node_selection"):
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.handle_f5_key(stdscr)
self.assertEqual(dialog_win.getch.call_count, 2)
handle_resize.assert_called_once_with(stdscr, False)
def test_f5_node_details_tolerates_none_metrics(self) -> None:
stdscr = mock.Mock()
ui_state.node_list = [101]
ui_state.selected_node = 0
ui_state.current_window = 2
dialog_win = mock.Mock()
dialog_win.getch.return_value = 27
msg_win = mock.Mock()
dialog_win.derwin.return_value = msg_win
interface = mock.Mock()
interface.nodesByNum = {
101: {
"num": 101,
"snr": None,
"hopsAway": None,
"deviceMetrics": {
"batteryLevel": None,
"channelUtilization": None,
"airUtilTx": None,
"uptimeSeconds": None,
},
"user": {
"longName": "Test Node",
"shortName": "TN",
"hwModel": "T-Beam",
"role": "CLIENT",
"publicKey": "abc",
},
}
}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui.curses, "LINES", 24, create=True):
with mock.patch.object(contact_ui.curses, "COLS", 80, create=True):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui.curses, "update_lines_cols"):
with mock.patch.object(contact_ui.curses, "doupdate"):
with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_node_selection"):
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.handle_f5_key(stdscr)
handle_resize.assert_called_once_with(stdscr, False)
+85
View File
@@ -45,6 +45,38 @@ class DbHandlerTests(unittest.TestCase):
self.assertEqual(row, ("123", "hello", "Ack"))
def test_message_ids_are_migrated_and_reloaded_with_history(self) -> None:
db_handler.ensure_table_exists(
'"123_Primary_messages"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.execute(
'INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)',
("456", "original message", 1700000000, None),
)
db_handler.save_message_to_db("Primary", "456", "reply", packet_id=902, reply_id=901)
with sqlite3.connect(config.db_file_path) as conn:
columns = {row[1] for row in conn.execute('PRAGMA table_info("123_Primary_messages")')}
stored_ids = conn.execute(
'SELECT packet_id, reply_id FROM "123_Primary_messages" WHERE message_text = ?', ("reply",)
).fetchone()
conn.execute(
'UPDATE "123_Primary_messages" SET packet_id = ? WHERE message_text = ?', (901, "original message")
)
conn.commit()
db_handler.load_messages_from_db()
self.assertTrue({"packet_id", "reply_id"}.issubset(columns))
self.assertEqual(stored_ids, (902, 901))
self.assertEqual([packet_id for packet_id in ui_state.message_packet_ids["Primary"] if packet_id], [901, 902])
self.assertEqual(
ui_state.all_messages["Primary"][-1][1],
f"<Re: {decimal_to_hex(456)}: origi> reply",
)
def test_update_node_info_in_db_fills_defaults_and_preserves_existing_values(self) -> None:
db_handler.update_node_info_in_db(999, short_name="ABCD")
@@ -112,6 +144,59 @@ class DbHandlerTests(unittest.TestCase):
self.assertTrue(any("RM:" in prefix for prefix, _ in messages))
self.assertEqual(ui_state.all_messages[789][-1][1], "hidden")
def test_message_history_is_loaded_in_bounded_pages(self) -> None:
db_handler.update_node_info_in_db(456, long_name="Remote Node", short_name="RM")
db_handler.ensure_table_exists(
'"123_Primary_messages"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.executemany(
'INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)',
[("456", f"message-{i}", 1700000000 + i, None) for i in range(7)],
)
db_handler.load_messages_from_db(page_size=3)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(loaded_text, ["message-4", "message-5", "message-6"])
self.assertTrue(ui_state.has_older_messages["Primary"])
self.assertEqual(db_handler.load_older_messages("Primary", page_size=3), 3)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(
loaded_text,
["message-1", "message-2", "message-3", "message-4", "message-5", "message-6"],
)
self.assertTrue(ui_state.has_older_messages["Primary"])
separators = [prefix for prefix, _ in ui_state.all_messages["Primary"] if prefix.startswith("--")]
self.assertEqual(len(separators), 2)
self.assertEqual(separators[0], separators[1])
self.assertEqual(db_handler.load_older_messages("Primary", page_size=3), 1)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(loaded_text, [f"message-{i}" for i in range(7)])
self.assertFalse(ui_state.has_older_messages["Primary"])
separators = [prefix for prefix, _ in ui_state.all_messages["Primary"] if prefix.startswith("--")]
self.assertEqual(len(separators), 3)
def test_message_table_channel_name_may_contain_underscores(self) -> None:
db_handler.ensure_node_table_exists()
db_handler.ensure_table_exists(
'"123_My_Channel_messages"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.execute(
'INSERT INTO "123_My_Channel_messages" VALUES (?, ?, ?, ?)',
("123", "hello", 1700000000, None),
)
db_handler.load_messages_from_db()
self.assertIn("My_Channel", ui_state.channel_list)
self.assertEqual(ui_state.all_messages["My_Channel"][-1][1], "hello")
def test_init_nodedb_inserts_nodes_from_interface(self) -> None:
interface_state.interface = build_demo_interface()
interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM
+88
View File
@@ -0,0 +1,88 @@
import unittest
from unittest import mock
from contact.ui import dialog as dialog_module
from contact.utilities.singleton import menu_state, ui_state
class _FakeWindow:
def __init__(self, height: int, width: int) -> None:
self.height = height
self.width = width
self.children = []
self.added_strings = []
self._getch_values = [10]
def erase(self) -> None:
return None
def bkgd(self, *_args) -> None:
return None
def attrset(self, *_args) -> None:
return None
def border(self, *_args) -> None:
return None
def addstr(self, y: int, x: int, text: str, *_args) -> None:
self.added_strings.append((y, x, text))
def derwin(self, height: int, width: int, _y: int, _x: int):
child = _FakeWindow(height, width)
self.children.append(child)
return child
def noutrefresh(self) -> None:
return None
def keypad(self, *_args) -> None:
return None
def timeout(self, *_args) -> None:
return None
def getch(self) -> int:
return self._getch_values.pop(0) if self._getch_values else -1
def refresh(self) -> None:
return None
def getmaxyx(self):
return (self.height, self.width)
class DialogTests(unittest.TestCase):
def setUp(self) -> None:
self.previous_window = ui_state.current_window
self.previous_start_index = list(ui_state.start_index)
self.previous_need_redraw = menu_state.need_redraw
def tearDown(self) -> None:
ui_state.current_window = self.previous_window
ui_state.start_index = self.previous_start_index
menu_state.need_redraw = self.previous_need_redraw
def test_dialog_renders_full_message_when_width_is_sufficient(self) -> None:
root = _FakeWindow(5, 33)
ui_state.current_window = 0
ui_state.start_index = [0, 0, 0]
menu_state.need_redraw = False
with mock.patch.object(dialog_module, "t_text", side_effect=lambda text: text):
with mock.patch.object(dialog_module, "get_color", return_value=0):
with mock.patch.object(dialog_module, "draw_main_arrows"):
with mock.patch.object(dialog_module.curses, "update_lines_cols"):
with mock.patch.object(dialog_module.curses, "doupdate"):
with mock.patch.object(dialog_module.curses, "LINES", 24, create=True):
with mock.patch.object(dialog_module.curses, "COLS", 80, create=True):
with mock.patch.object(dialog_module.curses, "newwin", return_value=root):
dialog_module.dialog("Bot Responder", "Bot responder is now Enabled.")
self.assertTrue(root.children)
message_text = [text for _y, _x, text in root.children[0].added_strings]
self.assertIn("Bot responder is now Enabled.", message_text)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -5,6 +5,7 @@ from unittest import mock
import contact.ui.default_config as config
from contact.utilities import i18n
from contact.utilities.ini_utils import parse_ini_file
from tests.test_support import restore_config, snapshot_config
@@ -55,3 +56,20 @@ class I18nTests(unittest.TestCase):
config.language = "ru"
self.assertEqual(i18n.t("missing", default="fallback"), "fallback")
self.assertEqual(parse_ini_file.call_count, 2)
def test_bot_ui_translation_keys_exist_in_all_locales(self) -> None:
required_keys = {
"ui.help.bot_responder",
"ui.bot.status.enabled",
"ui.bot.status.disabled",
"ui.bot.dialog.title",
"ui.bot.dialog.body",
"ui.bot.status.message",
"app_settings.ping_bot",
"app_settings.ping_bot.catch_words",
"app_settings.ping_bot.response_word",
}
for language in config.get_localisation_options():
field_mapping, _ = parse_ini_file(config.get_localisation_file(language))
self.assertTrue(required_keys.issubset(field_mapping), msg=f"Missing bot translation keys in {language}")
+21
View File
@@ -186,6 +186,15 @@ class MainRuntimeTests(unittest.TestCase):
wrapper.assert_called_once_with(entrypoint.main)
interface.close.assert_called_once_with()
def test_start_does_not_crash_when_wrapper_returns_without_interface(self) -> None:
interface_state.interface = None
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper") as wrapper:
entrypoint.start()
wrapper.assert_called_once_with(entrypoint.main)
def test_main_returns_cleanly_when_user_closes_missing_node_dialog(self) -> None:
stdscr = mock.Mock()
args = Namespace(settings=False, demo_screenshot=False)
@@ -215,6 +224,18 @@ class MainRuntimeTests(unittest.TestCase):
interface.close.assert_called_once_with()
exit_mock.assert_called_once_with(0)
def test_start_handles_keyboard_interrupt_with_no_interface(self) -> None:
interface_state.interface = None
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=KeyboardInterrupt):
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
with self.assertRaises(SystemExit) as raised:
entrypoint.start()
self.assertEqual(raised.exception.code, 0)
exit_mock.assert_called_once_with(0)
def test_start_handles_fatal_exception_and_exits_one(self) -> None:
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=RuntimeError("boom")):
+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"}}}