cb89039bb7
Anchor reaction lines to the end of the request-id property run instead of forward-line 1, so multi-line messages are not split. Bound the reaction-line scan by the reaction property rather than the prompt marker, so reactions to the last message merge instead of stacking duplicate lines.
2196 lines
85 KiB
EmacsLisp
2196 lines
85 KiB
EmacsLisp
;;; meshmonitor-chat.el --- Chat client for MeshMonitor (Meshtastic) -*- lexical-binding: t; -*-
|
|
|
|
;; Copyright (C) 2026 Andros Fenollosa
|
|
|
|
;; Author: Andros Fenollosa <hi@andros.dev>
|
|
;; Maintainer: Andros Fenollosa <hi@andros.dev>
|
|
;; Version: 1.0.0
|
|
;; Package-Requires: ((emacs "28.1"))
|
|
;; Keywords: comm
|
|
;; URL: https://git.andros.dev/andros/meshmonitor-chat.el
|
|
|
|
;; SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
;; This file is NOT part of GNU Emacs.
|
|
|
|
;; This program is free software; you can redistribute it and/or
|
|
;; modify it under the terms of the GNU General Public License as
|
|
;; published by the Free Software Foundation, either version 3 of the
|
|
;; License, or (at your option) any later version.
|
|
|
|
;; This program is distributed in the hope that it will be useful, but
|
|
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
;; General Public License for more details.
|
|
|
|
;; You should have received a copy of the GNU General Public License
|
|
;; along with this program. If not, see
|
|
;; <https://www.gnu.org/licenses/>.
|
|
|
|
;;; Commentary:
|
|
|
|
;; Emacs chat client for MeshMonitor, a web-based Meshtastic network
|
|
;; monitor. Provides channel and direct message chat buffers.
|
|
;;
|
|
;; Configure `meshmonitor-chat-host', `meshmonitor-chat-port' and
|
|
;; `meshmonitor-chat-token' in your init file. Then:
|
|
;;
|
|
;; M-x meshmonitor-chat - welcome screen
|
|
;; M-x meshmonitor-chat-channels - list channels
|
|
;; M-x meshmonitor-chat-nodes - list nodes by hops
|
|
;; M-x meshmonitor-chat-unread - nodes with unread DMs
|
|
;; M-x meshmonitor-chat-direct-messages - list DM conversations
|
|
|
|
;;; Code:
|
|
|
|
(require 'json)
|
|
(require 'url)
|
|
(require 'url-http)
|
|
(require 'cl-lib)
|
|
(require 'notifications)
|
|
(require 'ring)
|
|
(require 'seq)
|
|
|
|
;;;; Customization
|
|
|
|
(defgroup meshmonitor-chat nil
|
|
"Chat client for MeshMonitor (Meshtastic)."
|
|
:group 'communication
|
|
:prefix "meshmonitor-chat-")
|
|
|
|
(defcustom meshmonitor-chat-host ""
|
|
"MeshMonitor server hostname or IP address."
|
|
:type 'string)
|
|
|
|
(defcustom meshmonitor-chat-port 3000
|
|
"MeshMonitor server port."
|
|
:type 'integer)
|
|
|
|
(defcustom meshmonitor-chat-use-tls nil
|
|
"Non-nil means use HTTPS instead of HTTP."
|
|
:type 'boolean)
|
|
|
|
(defcustom meshmonitor-chat-token nil
|
|
"Bearer token for MeshMonitor API.
|
|
When set, username/password login is skipped."
|
|
:type '(choice (const nil) string))
|
|
|
|
(defcustom meshmonitor-chat-username "admin"
|
|
"Username for MeshMonitor authentication."
|
|
:type 'string)
|
|
|
|
(defcustom meshmonitor-chat-password ""
|
|
"Password for MeshMonitor authentication."
|
|
:type 'string)
|
|
|
|
(defcustom meshmonitor-chat-login-endpoint "/auth/login"
|
|
"API endpoint path for login."
|
|
:type 'string)
|
|
|
|
(defcustom meshmonitor-chat-poll-interval 10
|
|
"Seconds between polling for new messages."
|
|
:type 'integer)
|
|
|
|
(defcustom meshmonitor-chat-notify t
|
|
"Non-nil means show desktop notifications for new messages."
|
|
:type 'boolean)
|
|
|
|
(defcustom meshmonitor-chat-message-limit 50
|
|
"Number of messages to fetch per request."
|
|
:type 'integer)
|
|
|
|
(defcustom meshmonitor-chat-timestamp-format "%H:%M"
|
|
"Format string for message timestamps."
|
|
:type 'string)
|
|
|
|
;;;; Faces
|
|
|
|
(defface meshmonitor-chat-timestamp-face
|
|
'((t :foreground "gray50"))
|
|
"Face for message timestamps.")
|
|
|
|
(defface meshmonitor-chat-nick-self-face
|
|
'((t :foreground "sea green" :weight bold))
|
|
"Face for own nick in messages.")
|
|
|
|
(defface meshmonitor-chat-nick-other-face
|
|
'((t :foreground "dodger blue" :weight bold))
|
|
"Face for other nicks in messages.")
|
|
|
|
(defface meshmonitor-chat-prompt-face
|
|
'((t :foreground "cyan" :weight bold))
|
|
"Face for the input prompt.")
|
|
|
|
(defface meshmonitor-chat-system-face
|
|
'((t :foreground "gray60" :slant italic))
|
|
"Face for system messages.")
|
|
|
|
(defface meshmonitor-chat-delivery-pending-face
|
|
'((t :foreground "gray50"))
|
|
"Face for pending delivery icon.")
|
|
|
|
(defface meshmonitor-chat-delivery-confirmed-face
|
|
'((t :foreground "green"))
|
|
"Face for confirmed delivery icon.")
|
|
|
|
(defface meshmonitor-chat-delivery-failed-face
|
|
'((t :foreground "red"))
|
|
"Face for failed delivery icon.")
|
|
|
|
;;;; Internal state
|
|
|
|
(defvar meshmonitor-chat--base-url nil
|
|
"Base URL for the MeshMonitor instance.")
|
|
|
|
(defvar meshmonitor-chat--auth-token nil
|
|
"Bearer token for API requests.")
|
|
|
|
(defvar meshmonitor-chat--csrf-token nil
|
|
"CSRF token for session-authenticated requests.")
|
|
|
|
(defvar meshmonitor-chat--connected nil
|
|
"Non-nil when connected to MeshMonitor.")
|
|
|
|
(defvar meshmonitor-chat--nodes (make-hash-table :test 'equal)
|
|
"Hash table mapping node IDs and nums to node alists.")
|
|
|
|
(defvar meshmonitor-chat--my-node-id nil
|
|
"Node ID of the connected MeshMonitor node.")
|
|
|
|
(defvar meshmonitor-chat--my-node-num nil
|
|
"Node number of the connected MeshMonitor node.")
|
|
|
|
(defvar meshmonitor-chat--channels nil
|
|
"Cached list of channel alists from the API.")
|
|
|
|
(defvar meshmonitor-chat--chat-buffers nil
|
|
"Alist of ((TYPE . TARGET) . BUFFER) for open chat buffers.")
|
|
|
|
(defvar meshmonitor-chat--read-timestamps (make-hash-table :test 'equal)
|
|
"Hash table mapping node IDs to last-read Unix timestamp.")
|
|
|
|
(defvar meshmonitor-chat--poll-timer nil
|
|
"Timer for periodic message polling.")
|
|
|
|
(defvar meshmonitor-chat--last-poll-time nil
|
|
"Timestamp of the last successful poll, for gap detection.")
|
|
|
|
;;;; Buffer-local variables
|
|
|
|
(defvar-local meshmonitor-chat--target nil
|
|
"Target for this buffer: channel number or node ID string.")
|
|
|
|
(defvar-local meshmonitor-chat--target-type nil
|
|
"Type of target: symbol `channel' or `dm'.")
|
|
|
|
(defvar-local meshmonitor-chat--prompt-start nil
|
|
"Marker at the beginning of the prompt.")
|
|
|
|
(defvar-local meshmonitor-chat--prompt-end nil
|
|
"Marker at the end of the prompt.")
|
|
|
|
(defvar-local meshmonitor-chat--last-timestamp nil
|
|
"Unix timestamp of the last rendered message.")
|
|
|
|
(defvar-local meshmonitor-chat--seen-ids nil
|
|
"Hash table of rendered message IDs for deduplication.")
|
|
|
|
(defvar-local meshmonitor-chat--input-ring nil
|
|
"Ring holding previous input strings.")
|
|
|
|
(defvar-local meshmonitor-chat--input-ring-index 0
|
|
"Current index in the input ring.")
|
|
|
|
(defvar-local meshmonitor-chat--pending-deliveries nil
|
|
"Alist of (REQUEST-ID . MARKER) for pending delivery icons.")
|
|
|
|
(defvar-local meshmonitor-chat--reply-to nil
|
|
"Cons of (REQUEST-ID . SENDER-NAME) for the message being replied to.")
|
|
|
|
(defvar-local meshmonitor-chat--reactions nil
|
|
"Hash table mapping requestId to list of (EMOJI . SENDER) pairs.")
|
|
|
|
;;;; Timestamp helpers
|
|
|
|
(defun meshmonitor-chat--parse-timestamp (ts)
|
|
"Convert TS to a Unix timestamp in seconds.
|
|
Handles millisecond timestamps from MeshMonitor API."
|
|
(cond
|
|
((numberp ts)
|
|
(if (> ts 9999999999) (/ ts 1000) ts))
|
|
((stringp ts)
|
|
(condition-case nil
|
|
(truncate (float-time (date-to-time ts)))
|
|
(error 0)))
|
|
(t 0)))
|
|
|
|
(defun meshmonitor-chat--format-time (ts)
|
|
"Format timestamp TS for display."
|
|
(condition-case nil
|
|
(format-time-string
|
|
meshmonitor-chat-timestamp-format
|
|
(seconds-to-time (meshmonitor-chat--parse-timestamp ts)))
|
|
(error "??:??")))
|
|
|
|
;;;; HTTP helpers
|
|
|
|
(defun meshmonitor-chat--build-url (endpoint)
|
|
"Build full URL for API ENDPOINT."
|
|
(concat meshmonitor-chat--base-url endpoint))
|
|
|
|
(defun meshmonitor-chat--request (method endpoint &optional data callback)
|
|
"Make an HTTP request with METHOD to ENDPOINT.
|
|
DATA is an alist to send as JSON body.
|
|
When CALLBACK is non-nil, make an async request and call
|
|
CALLBACK with (STATUS-CODE . JSON-BODY) or nil on error.
|
|
When CALLBACK is nil, make a synchronous request and return
|
|
the same cons cell."
|
|
(let ((url-request-method method)
|
|
(url-cookie-confirmation nil)
|
|
(url-request-extra-headers
|
|
(append '(("Content-Type" . "application/json")
|
|
("Accept" . "application/json"))
|
|
(when meshmonitor-chat--auth-token
|
|
`(("Authorization"
|
|
. ,(concat "Bearer "
|
|
meshmonitor-chat--auth-token))))))
|
|
(url-request-data
|
|
(when data
|
|
(encode-coding-string (json-encode data) 'utf-8)))
|
|
(full-url (meshmonitor-chat--build-url endpoint)))
|
|
(if callback
|
|
(url-retrieve
|
|
full-url
|
|
(lambda (status cb)
|
|
(let ((result nil)
|
|
(resp-buf (current-buffer)))
|
|
(unwind-protect
|
|
(progn
|
|
(unless (plist-get status :error)
|
|
(setq result
|
|
(meshmonitor-chat--parse-response)))
|
|
(funcall cb result))
|
|
(when (buffer-live-p resp-buf)
|
|
(kill-buffer resp-buf)))))
|
|
(list callback) t)
|
|
(let ((buf (url-retrieve-synchronously full-url t nil 15)))
|
|
(when buf
|
|
(unwind-protect
|
|
(with-current-buffer buf
|
|
(meshmonitor-chat--parse-response))
|
|
(kill-buffer buf)))))))
|
|
|
|
(defun meshmonitor-chat--parse-response ()
|
|
"Parse HTTP response in current buffer.
|
|
Return (STATUS-CODE . JSON-BODY) or (STATUS-CODE . nil)."
|
|
(goto-char (point-min))
|
|
(let ((status-code 0))
|
|
(when (re-search-forward "HTTP/[0-9.]+ \\([0-9]+\\)" nil t)
|
|
(setq status-code (string-to-number (match-string 1))))
|
|
(goto-char (point-min))
|
|
(if (re-search-forward "\r?\n\r?\n" nil t)
|
|
(condition-case nil
|
|
(progn
|
|
;; url.el returns a unibyte buffer; convert to
|
|
;; multibyte so json-read decodes UTF-8 correctly.
|
|
(set-buffer-multibyte t)
|
|
(let ((json-object-type 'alist)
|
|
(json-array-type 'list)
|
|
(json-key-type 'symbol))
|
|
(cons status-code (json-read))))
|
|
(error (cons status-code nil)))
|
|
(cons status-code nil))))
|
|
|
|
;;;; Authentication and connection
|
|
|
|
(defun meshmonitor-chat--login ()
|
|
"Login to MeshMonitor using configured credentials.
|
|
Return non-nil on success."
|
|
(let ((scheme (if meshmonitor-chat-use-tls "https" "http")))
|
|
(setq meshmonitor-chat--base-url
|
|
(format "%s://%s:%d" scheme
|
|
meshmonitor-chat-host
|
|
meshmonitor-chat-port)))
|
|
(cond
|
|
(meshmonitor-chat-token
|
|
(setq meshmonitor-chat--auth-token meshmonitor-chat-token)
|
|
t)
|
|
(t
|
|
(let ((result (meshmonitor-chat--request
|
|
"POST" meshmonitor-chat-login-endpoint
|
|
`((username . ,meshmonitor-chat-username)
|
|
(password . ,meshmonitor-chat-password)))))
|
|
(when result
|
|
(let ((body (cdr result)))
|
|
(when body
|
|
(let ((token (or (alist-get 'token body)
|
|
(alist-get 'accessToken body))))
|
|
(when token
|
|
(setq meshmonitor-chat--auth-token token)))))
|
|
(< (car result) 400))))))
|
|
|
|
(defun meshmonitor-chat--ensure-connected ()
|
|
"Ensure connection to MeshMonitor, auto-connecting if needed."
|
|
(unless meshmonitor-chat--connected
|
|
(when (string-empty-p meshmonitor-chat-host)
|
|
(user-error "Set `meshmonitor-chat-host' in your init file"))
|
|
(unless (meshmonitor-chat--login)
|
|
(user-error "MeshMonitor: login failed at %s"
|
|
meshmonitor-chat--base-url))
|
|
(setq meshmonitor-chat--connected t)
|
|
;; Fetch status (sync) for our node identity.
|
|
(let ((info (meshmonitor-chat--fetch-status-sync)))
|
|
(when info
|
|
(let ((status (meshmonitor-chat--normalize-status
|
|
(cdr info))))
|
|
(setq meshmonitor-chat--my-node-id
|
|
(alist-get 'node-id status))
|
|
(setq meshmonitor-chat--my-node-num
|
|
(alist-get 'node-num status)))))
|
|
;; Fetch nodes (sync) for name resolution.
|
|
(meshmonitor-chat--fetch-nodes-sync)
|
|
;; Fetch channels (sync).
|
|
(meshmonitor-chat--fetch-channels-sync)
|
|
;; Start polling.
|
|
(meshmonitor-chat--start-polling)
|
|
(message "MeshMonitor: connected to %s" meshmonitor-chat-host))
|
|
t)
|
|
|
|
;;;; Node resolution
|
|
|
|
(defun meshmonitor-chat--process-nodes (data)
|
|
"Process and cache node DATA list."
|
|
(when (listp data)
|
|
(clrhash meshmonitor-chat--nodes)
|
|
(dolist (node data)
|
|
(let ((num (alist-get 'nodeNum node))
|
|
(id (alist-get 'nodeId node)))
|
|
(when num
|
|
(puthash (number-to-string num) node
|
|
meshmonitor-chat--nodes))
|
|
(when id
|
|
(puthash id node meshmonitor-chat--nodes))))))
|
|
|
|
(defun meshmonitor-chat--fetch-nodes-sync ()
|
|
"Fetch and cache nodes synchronously."
|
|
(let ((result (meshmonitor-chat--request "GET" "/api/v1/nodes")))
|
|
(when result
|
|
(meshmonitor-chat--process-nodes
|
|
(alist-get 'data (cdr result))))))
|
|
|
|
(defun meshmonitor-chat--fetch-nodes (&optional callback)
|
|
"Fetch and cache nodes asynchronously.
|
|
Call CALLBACK with no arguments when done."
|
|
(meshmonitor-chat--request
|
|
"GET" "/api/v1/nodes" nil
|
|
(lambda (result)
|
|
(when result
|
|
(meshmonitor-chat--process-nodes
|
|
(alist-get 'data (cdr result))))
|
|
(when callback (funcall callback)))))
|
|
|
|
(defun meshmonitor-chat--node-name (id)
|
|
"Return display name for node ID.
|
|
Falls back to ID itself when no name is cached."
|
|
(let* ((key (if (numberp id) (number-to-string id) (or id "")))
|
|
(node (gethash key meshmonitor-chat--nodes)))
|
|
(if node
|
|
(or (alist-get 'longName node)
|
|
(alist-get 'shortName node)
|
|
key)
|
|
key)))
|
|
|
|
;;;; Channel helpers
|
|
|
|
(defun meshmonitor-chat--fetch-channels-sync ()
|
|
"Fetch and cache channels synchronously."
|
|
(let ((result (meshmonitor-chat--request
|
|
"GET" "/api/v1/channels")))
|
|
(when result
|
|
(setq meshmonitor-chat--channels
|
|
(alist-get 'data (cdr result))))))
|
|
|
|
(defun meshmonitor-chat--fetch-channels (&optional callback)
|
|
"Fetch channels asynchronously.
|
|
Call CALLBACK with the channel list when done."
|
|
(meshmonitor-chat--request
|
|
"GET" "/api/v1/channels" nil
|
|
(lambda (result)
|
|
(when result
|
|
(setq meshmonitor-chat--channels
|
|
(alist-get 'data (cdr result))))
|
|
(when callback
|
|
(funcall callback meshmonitor-chat--channels)))))
|
|
|
|
(defun meshmonitor-chat--channel-name (id)
|
|
"Return display name for channel ID."
|
|
(let ((ch (seq-find (lambda (c) (equal (alist-get 'id c) id))
|
|
meshmonitor-chat--channels)))
|
|
(if ch
|
|
(or (let ((name (alist-get 'name ch)))
|
|
(and name (not (string-empty-p name)) name))
|
|
(format "%d" id))
|
|
(format "%d" id))))
|
|
|
|
;;;; API wrappers
|
|
|
|
(defun meshmonitor-chat--api-messages (params callback)
|
|
"Fetch messages with query PARAMS alist.
|
|
Call CALLBACK with (STATUS . BODY)."
|
|
(let ((query (mapconcat
|
|
(lambda (p)
|
|
(format "%s=%s"
|
|
(url-hexify-string (symbol-name (car p)))
|
|
(url-hexify-string (format "%s" (cdr p)))))
|
|
params "&")))
|
|
(meshmonitor-chat--request
|
|
"GET" (concat "/api/v1/messages?" query) nil callback)))
|
|
|
|
(defun meshmonitor-chat--api-send (text &optional channel to-node
|
|
reply-id callback)
|
|
"Send TEXT message to CHANNEL or TO-NODE.
|
|
REPLY-ID is the requestId of the message being replied to.
|
|
Call CALLBACK with (STATUS . BODY)."
|
|
(let ((data `((text . ,text))))
|
|
(when channel (push `(channel . ,channel) data))
|
|
(when to-node (push `(toNodeId . ,to-node) data))
|
|
(when reply-id (push `(replyId . ,reply-id) data))
|
|
(meshmonitor-chat--request
|
|
"POST" "/api/v1/messages" data callback)))
|
|
|
|
;;;; Session-authenticated requests (for internal API)
|
|
|
|
(defun meshmonitor-chat--ensure-session ()
|
|
"Ensure we have a session cookie and CSRF token.
|
|
Logs in with username/password if needed."
|
|
(unless meshmonitor-chat--csrf-token
|
|
(when (and (not (string-empty-p meshmonitor-chat-username))
|
|
(not (string-empty-p meshmonitor-chat-password)))
|
|
;; Login to get session cookie (url.el stores it automatically).
|
|
(let ((url-cookie-confirmation nil))
|
|
(meshmonitor-chat--request
|
|
"POST" meshmonitor-chat-login-endpoint
|
|
`((username . ,meshmonitor-chat-username)
|
|
(password . ,meshmonitor-chat-password))))
|
|
;; Fetch CSRF token.
|
|
(let ((result (meshmonitor-chat--request
|
|
"GET" "/api/csrf-token")))
|
|
(when result
|
|
(let ((token (alist-get 'csrfToken (cdr result))))
|
|
(when token
|
|
(setq meshmonitor-chat--csrf-token token))))))))
|
|
|
|
(defun meshmonitor-chat--session-request (method endpoint
|
|
&optional data
|
|
callback)
|
|
"Make a session-authenticated request with METHOD to ENDPOINT.
|
|
Includes CSRF token for POST requests.
|
|
DATA is an alist for the JSON body.
|
|
CALLBACK works like `meshmonitor-chat--request'."
|
|
(meshmonitor-chat--ensure-session)
|
|
(let ((url-request-method method)
|
|
(url-cookie-confirmation nil)
|
|
(url-request-extra-headers
|
|
(append '(("Content-Type" . "application/json")
|
|
("Accept" . "application/json"))
|
|
(when meshmonitor-chat--csrf-token
|
|
`(("X-CSRF-Token"
|
|
. ,meshmonitor-chat--csrf-token)))))
|
|
(url-request-data
|
|
(when data
|
|
(encode-coding-string (json-encode data) 'utf-8)))
|
|
(full-url (meshmonitor-chat--build-url endpoint)))
|
|
(if callback
|
|
(url-retrieve
|
|
full-url
|
|
(lambda (status cb)
|
|
(let ((result nil)
|
|
(resp-buf (current-buffer)))
|
|
(unwind-protect
|
|
(progn
|
|
(unless (plist-get status :error)
|
|
(setq result
|
|
(meshmonitor-chat--parse-response)))
|
|
(funcall cb result))
|
|
(when (buffer-live-p resp-buf)
|
|
(kill-buffer resp-buf)))))
|
|
(list callback) t)
|
|
(let ((buf (url-retrieve-synchronously full-url t nil 15)))
|
|
(when buf
|
|
(unwind-protect
|
|
(with-current-buffer buf
|
|
(meshmonitor-chat--parse-response))
|
|
(kill-buffer buf)))))))
|
|
|
|
;;;; Node actions
|
|
|
|
(defun meshmonitor-chat--node-has-pkc-p (node-id)
|
|
"Return non-nil if NODE-ID has exchanged encryption keys."
|
|
(let* ((node (gethash node-id meshmonitor-chat--nodes)))
|
|
(and node
|
|
(meshmonitor-chat--json-true-p
|
|
(alist-get 'hasPKC node)))))
|
|
|
|
(defun meshmonitor-chat-traceroute ()
|
|
"Send a traceroute to the node at point."
|
|
(interactive)
|
|
(let ((node-id (meshmonitor-chat--get-node-id-at-point)))
|
|
(if node-id
|
|
(progn
|
|
(message "MeshMonitor: traceroute to %s..."
|
|
(meshmonitor-chat--node-name node-id))
|
|
(meshmonitor-chat--session-request
|
|
"POST" "/api/traceroute"
|
|
`((destination . ,node-id))
|
|
(lambda (result)
|
|
(if (and result (< (car result) 400))
|
|
(message "MeshMonitor: traceroute sent to %s"
|
|
(meshmonitor-chat--node-name node-id))
|
|
(message "MeshMonitor: traceroute failed (HTTP %s)"
|
|
(if result (car result) "timeout"))))))
|
|
(user-error "No node at point"))))
|
|
|
|
(defun meshmonitor-chat-request-position ()
|
|
"Request position from the node at point."
|
|
(interactive)
|
|
(let ((node-id (meshmonitor-chat--get-node-id-at-point)))
|
|
(if node-id
|
|
(progn
|
|
(message "MeshMonitor: requesting position from %s..."
|
|
(meshmonitor-chat--node-name node-id))
|
|
(meshmonitor-chat--session-request
|
|
"POST" "/api/position/request"
|
|
`((destination . ,node-id))
|
|
(lambda (result)
|
|
(if (and result (< (car result) 400))
|
|
(message "MeshMonitor: position requested from %s"
|
|
(meshmonitor-chat--node-name node-id))
|
|
(message "MeshMonitor: position request failed (HTTP %s)"
|
|
(if result (car result) "timeout"))))))
|
|
(user-error "No node at point"))))
|
|
|
|
(defun meshmonitor-chat--get-node-id-at-point ()
|
|
"Get node ID from the current tabulated-list entry."
|
|
(let ((entry (tabulated-list-get-entry)))
|
|
(when entry
|
|
;; Node ID is in column index 2 for node list, or column 1 for DM/unread.
|
|
(or (and (> (length entry) 2) (aref entry 2))
|
|
(and (> (length entry) 1) (aref entry 1))))))
|
|
|
|
;;;; Delivery icons
|
|
|
|
(defun meshmonitor-chat--delivery-icon (state)
|
|
"Return propertized delivery icon string for STATE."
|
|
(let ((icon (pcase state
|
|
('confirmed "✓")
|
|
('failed "✗")
|
|
(_ "·")))
|
|
(face (pcase state
|
|
('confirmed 'meshmonitor-chat-delivery-confirmed-face)
|
|
('failed 'meshmonitor-chat-delivery-failed-face)
|
|
(_ 'meshmonitor-chat-delivery-pending-face))))
|
|
(propertize icon 'face face 'read-only t 'rear-nonsticky t)))
|
|
|
|
(defun meshmonitor-chat--update-delivery-icon (request-id state)
|
|
"Update delivery icon for REQUEST-ID to STATE in current buffer."
|
|
(let ((entry (assoc request-id meshmonitor-chat--pending-deliveries)))
|
|
(when entry
|
|
(let ((marker (cdr entry))
|
|
(inhibit-read-only t))
|
|
(when (and marker (marker-buffer marker))
|
|
(save-excursion
|
|
(goto-char marker)
|
|
(delete-char 1)
|
|
(insert (meshmonitor-chat--delivery-icon state))
|
|
(set-marker marker (1- (point))))))
|
|
(unless (eq state 'pending)
|
|
(setq meshmonitor-chat--pending-deliveries
|
|
(delq entry meshmonitor-chat--pending-deliveries))))))
|
|
|
|
(defun meshmonitor-chat--check-delivery (msg buffer)
|
|
"Check if MSG confirms delivery of a pending message in BUFFER."
|
|
(when (buffer-live-p buffer)
|
|
(let ((req-id (alist-get 'requestId msg)))
|
|
(when req-id
|
|
(with-current-buffer buffer
|
|
(when (assoc req-id meshmonitor-chat--pending-deliveries)
|
|
(let ((state (cond
|
|
((equal (alist-get 'ackFailed msg) 1)
|
|
'failed)
|
|
((member (alist-get 'deliveryState msg)
|
|
'("confirmed" "delivered"))
|
|
'confirmed)
|
|
(t nil))))
|
|
(when state
|
|
(meshmonitor-chat--update-delivery-icon
|
|
req-id state)
|
|
(let ((msg-id (alist-get 'id msg)))
|
|
(when msg-id
|
|
(puthash msg-id t
|
|
meshmonitor-chat--seen-ids)))
|
|
(puthash (cons 'req req-id) t
|
|
meshmonitor-chat--seen-ids)))))))))
|
|
|
|
;;;; Chat mode
|
|
|
|
(defvar meshmonitor-chat-mode-map
|
|
(let ((map (make-sparse-keymap)))
|
|
(define-key map (kbd "RET") #'meshmonitor-chat-send-input)
|
|
(define-key map (kbd "M-p") #'meshmonitor-chat-previous-input)
|
|
(define-key map (kbd "M-n") #'meshmonitor-chat-next-input)
|
|
(define-key map (kbd "C-c C-r") #'meshmonitor-chat-reply)
|
|
(define-key map (kbd "C-c C-s") #'meshmonitor-chat-resend)
|
|
(define-key map (kbd "C-c C-e") #'meshmonitor-chat-react)
|
|
(define-key map (kbd "C-c C-k") #'meshmonitor-chat-cancel-reply)
|
|
(define-key map (kbd "C-c C-d") #'meshmonitor-chat-dm-at-point)
|
|
(define-key map (kbd "C-c C-l") #'meshmonitor-chat-refresh-chat)
|
|
map)
|
|
"Keymap for `meshmonitor-chat-mode'.")
|
|
|
|
(define-derived-mode meshmonitor-chat-mode fundamental-mode "MeshChat"
|
|
"Major mode for MeshMonitor chat buffers.
|
|
Provides an input prompt at the bottom with message history above."
|
|
:group 'meshmonitor-chat
|
|
(setq-local meshmonitor-chat--prompt-start (make-marker))
|
|
(setq-local meshmonitor-chat--prompt-end (make-marker))
|
|
(setq-local meshmonitor-chat--input-ring (make-ring 64))
|
|
(setq-local meshmonitor-chat--input-ring-index 0)
|
|
(setq-local meshmonitor-chat--seen-ids
|
|
(make-hash-table :test 'equal))
|
|
(setq-local meshmonitor-chat--pending-deliveries nil)
|
|
(setq-local meshmonitor-chat--reactions
|
|
(make-hash-table :test 'equal))
|
|
(setq-local meshmonitor-chat--last-timestamp nil)
|
|
(setq mode-line-process
|
|
'(" " (:eval (meshmonitor-chat--mode-line-status))))
|
|
(add-hook 'post-command-hook #'force-mode-line-update nil t))
|
|
|
|
(defvar meshmonitor-chat--max-message-bytes 600
|
|
"Maximum message size in bytes (3 parts x ~200 bytes).")
|
|
|
|
(defun meshmonitor-chat--input-byte-length ()
|
|
"Return the byte length of the current input text."
|
|
(if (and meshmonitor-chat--prompt-end
|
|
(marker-position meshmonitor-chat--prompt-end))
|
|
(string-bytes
|
|
(buffer-substring-no-properties
|
|
meshmonitor-chat--prompt-end (point-max)))
|
|
0))
|
|
|
|
(defun meshmonitor-chat--mode-line-status ()
|
|
"Return mode-line status string with connection and input size."
|
|
(let* ((conn (if meshmonitor-chat--connected "[on]" "[off]"))
|
|
(len (meshmonitor-chat--input-byte-length)))
|
|
(if (> len 0)
|
|
(let ((face (cond
|
|
((> len meshmonitor-chat--max-message-bytes)
|
|
'error)
|
|
((> len 200) 'warning)
|
|
(t nil))))
|
|
(format "%s %s"
|
|
conn
|
|
(if face
|
|
(propertize (format "[%d/%d B]" len
|
|
meshmonitor-chat--max-message-bytes)
|
|
'face face)
|
|
(format "[%d B]" len))))
|
|
conn)))
|
|
|
|
(defun meshmonitor-chat--prompt-string ()
|
|
"Return the prompt string for the current buffer."
|
|
(let ((base (pcase meshmonitor-chat--target-type
|
|
('channel (format "#%s> "
|
|
(meshmonitor-chat--channel-name
|
|
meshmonitor-chat--target)))
|
|
('dm (format "%s> "
|
|
(meshmonitor-chat--node-name
|
|
meshmonitor-chat--target)))
|
|
(_ "MeshMonitor> "))))
|
|
(if meshmonitor-chat--reply-to
|
|
(format "[reply %s] %s"
|
|
(cdr meshmonitor-chat--reply-to) base)
|
|
base)))
|
|
|
|
(defun meshmonitor-chat--setup-prompt ()
|
|
"Insert the input prompt at the end of the current buffer."
|
|
(goto-char (point-max))
|
|
(let ((start (point))
|
|
(txt (meshmonitor-chat--prompt-string)))
|
|
(insert (propertize txt
|
|
'face 'meshmonitor-chat-prompt-face
|
|
'read-only t
|
|
'front-sticky t
|
|
'rear-nonsticky t))
|
|
(set-marker meshmonitor-chat--prompt-start start)
|
|
(set-marker meshmonitor-chat--prompt-end (point))))
|
|
|
|
(defun meshmonitor-chat--refresh-prompt ()
|
|
"Refresh the prompt text, preserving input."
|
|
(let ((inhibit-read-only t)
|
|
(input (buffer-substring-no-properties
|
|
meshmonitor-chat--prompt-end (point-max))))
|
|
(save-excursion
|
|
(delete-region meshmonitor-chat--prompt-start (point-max))
|
|
(goto-char meshmonitor-chat--prompt-start)
|
|
(let ((txt (meshmonitor-chat--prompt-string)))
|
|
(insert (propertize txt
|
|
'face 'meshmonitor-chat-prompt-face
|
|
'read-only t
|
|
'front-sticky t
|
|
'rear-nonsticky t))
|
|
(set-marker meshmonitor-chat--prompt-end (point)))
|
|
(insert input))))
|
|
|
|
;;;; Message rendering
|
|
|
|
(defun meshmonitor-chat--insert-at-prompt (buffer fn)
|
|
"In BUFFER, execute FN at the prompt insertion point.
|
|
Handles marker manipulation and cursor restoration."
|
|
(when (buffer-live-p buffer)
|
|
(with-current-buffer buffer
|
|
(let ((inhibit-read-only t)
|
|
(at-end (>= (point) meshmonitor-chat--prompt-end)))
|
|
(save-excursion
|
|
(set-marker-insertion-type
|
|
meshmonitor-chat--prompt-start t)
|
|
(set-marker-insertion-type
|
|
meshmonitor-chat--prompt-end t)
|
|
(goto-char meshmonitor-chat--prompt-start)
|
|
(funcall fn)
|
|
(set-marker-insertion-type
|
|
meshmonitor-chat--prompt-start nil)
|
|
(set-marker-insertion-type
|
|
meshmonitor-chat--prompt-end nil))
|
|
(when at-end
|
|
(goto-char meshmonitor-chat--prompt-end))))))
|
|
|
|
(defun meshmonitor-chat--insert-msg (buffer ts sender text
|
|
&optional selfp sysp
|
|
request-id from-id
|
|
reply-to-name)
|
|
"Insert a chat message into BUFFER.
|
|
TS is the timestamp, SENDER the display name, TEXT the content.
|
|
SELFP non-nil marks the message as from the local node.
|
|
SYSP non-nil renders a system notification instead.
|
|
REQUEST-ID is stored as text property for reply support.
|
|
FROM-ID is the sender node ID for opening DMs.
|
|
REPLY-TO-NAME shows a reply indicator when non-nil."
|
|
(meshmonitor-chat--insert-at-prompt
|
|
buffer
|
|
(lambda ()
|
|
(insert
|
|
(propertize
|
|
(if sysp
|
|
(format "*** %s\n" text)
|
|
(concat
|
|
(propertize
|
|
(format "[%s] "
|
|
(if ts
|
|
(meshmonitor-chat--format-time ts)
|
|
"--:--"))
|
|
'face 'meshmonitor-chat-timestamp-face)
|
|
(propertize
|
|
(format "<%s> " sender)
|
|
'face (if selfp
|
|
'meshmonitor-chat-nick-self-face
|
|
'meshmonitor-chat-nick-other-face))
|
|
(when reply-to-name
|
|
(propertize (format "↩ %s: " reply-to-name)
|
|
'face 'meshmonitor-chat-system-face))
|
|
text "\n"))
|
|
'read-only t
|
|
'rear-nonsticky t
|
|
'front-sticky t
|
|
'face (when sysp 'meshmonitor-chat-system-face)
|
|
'meshmonitor-chat-msg-text text
|
|
'meshmonitor-chat-request-id request-id
|
|
'meshmonitor-chat-sender sender
|
|
'meshmonitor-chat-from-id from-id)))))
|
|
|
|
(defun meshmonitor-chat--insert-sent-msg (buffer ts sender text
|
|
delivery
|
|
&optional request-id)
|
|
"Insert a self message into BUFFER with DELIVERY state icon.
|
|
TS is the timestamp, SENDER the display name, TEXT the content.
|
|
DELIVERY is a symbol: `pending', `confirmed' or `failed'.
|
|
REQUEST-ID enables delivery tracking when DELIVERY is `pending'."
|
|
(meshmonitor-chat--insert-at-prompt
|
|
buffer
|
|
(lambda ()
|
|
(insert
|
|
(propertize
|
|
(concat
|
|
(propertize
|
|
(format "[%s] "
|
|
(if ts
|
|
(meshmonitor-chat--format-time ts)
|
|
"--:--"))
|
|
'face 'meshmonitor-chat-timestamp-face)
|
|
(propertize (format "<%s> " sender)
|
|
'face 'meshmonitor-chat-nick-self-face)
|
|
text " ")
|
|
'read-only t 'rear-nonsticky t 'front-sticky t
|
|
'meshmonitor-chat-msg-text text))
|
|
(let ((icon-pos (point)))
|
|
(insert (meshmonitor-chat--delivery-icon delivery))
|
|
(insert (propertize "\n" 'read-only t 'rear-nonsticky t))
|
|
(when (and request-id (eq delivery 'pending))
|
|
(push (cons request-id (copy-marker icon-pos))
|
|
meshmonitor-chat--pending-deliveries))))))
|
|
|
|
(defun meshmonitor-chat--is-self-p (msg)
|
|
"Return non-nil if MSG is from the local node."
|
|
(let ((from-id (alist-get 'fromNodeId msg))
|
|
(from-num (alist-get 'fromNodeNum msg)))
|
|
(or (and meshmonitor-chat--my-node-id from-id
|
|
(equal from-id meshmonitor-chat--my-node-id))
|
|
(and meshmonitor-chat--my-node-num from-num
|
|
(equal from-num meshmonitor-chat--my-node-num)))))
|
|
|
|
(defun meshmonitor-chat--msg-delivery-state (msg)
|
|
"Return delivery state symbol for MSG, or nil."
|
|
(cond
|
|
((equal (alist-get 'ackFailed msg) 1) 'failed)
|
|
((alist-get 'deliveryState msg)
|
|
(let ((ds (alist-get 'deliveryState msg)))
|
|
(cond
|
|
((member ds '("confirmed" "delivered")) 'confirmed)
|
|
((member ds '("failed" "error")) 'failed)
|
|
((equal ds "pending") 'pending)
|
|
(t nil))))
|
|
(t nil)))
|
|
|
|
(defun meshmonitor-chat--extract-request-id (msg)
|
|
"Extract the request ID from MSG for reply support.
|
|
Use the requestId field if present, otherwise extract from
|
|
the id field (format nodeNum_requestId)."
|
|
(or (alist-get 'requestId msg)
|
|
(let ((id (alist-get 'id msg)))
|
|
(when (and id (stringp id) (string-match "_\\([0-9]+\\)$" id))
|
|
(string-to-number (match-string 1 id))))))
|
|
|
|
(defun meshmonitor-chat--text-message-p (msg)
|
|
"Return non-nil if MSG is a text message (not traceroute etc)."
|
|
(let ((pn (alist-get 'portnum msg)))
|
|
(or (null pn) (equal pn 1))))
|
|
|
|
(defun meshmonitor-chat--reaction-p (msg)
|
|
"Return non-nil if MSG is an emoji reaction."
|
|
(equal (alist-get 'emoji msg) 1))
|
|
|
|
(defun meshmonitor-chat--find-reply-sender (buffer reply-id)
|
|
"Find the sender name of the message with REQUEST-ID REPLY-ID in BUFFER."
|
|
(when (and reply-id (buffer-live-p buffer))
|
|
(with-current-buffer buffer
|
|
(save-excursion
|
|
(goto-char (point-min))
|
|
(let ((limit (marker-position meshmonitor-chat--prompt-start))
|
|
(found nil))
|
|
(while (and (not found) (< (point) limit))
|
|
(when (equal (get-text-property (point)
|
|
'meshmonitor-chat-request-id)
|
|
reply-id)
|
|
(setq found (get-text-property (point)
|
|
'meshmonitor-chat-sender)))
|
|
(goto-char (or (next-single-property-change
|
|
(point) 'meshmonitor-chat-request-id
|
|
nil limit)
|
|
limit)))
|
|
found)))))
|
|
|
|
(defun meshmonitor-chat--find-request-id-pos (request-id)
|
|
"Find buffer position of message with REQUEST-ID, or nil."
|
|
(save-excursion
|
|
(goto-char (point-min))
|
|
(let ((limit (marker-position meshmonitor-chat--prompt-start))
|
|
(found nil))
|
|
(while (and (not found) (< (point) limit))
|
|
(if (equal (get-text-property (point)
|
|
'meshmonitor-chat-request-id)
|
|
request-id)
|
|
(setq found (point))
|
|
(goto-char (or (next-single-property-change
|
|
(point) 'meshmonitor-chat-request-id
|
|
nil limit)
|
|
limit))))
|
|
found)))
|
|
|
|
(defun meshmonitor-chat--render-reaction-line (buffer reply-id)
|
|
"Render the reaction line for REPLY-ID in BUFFER.
|
|
All consecutive reaction lines under the anchor message are merged
|
|
into a single line so that reactions arriving with distinct REPLY-ID
|
|
values but targeting the same visible message no longer stack."
|
|
(when (buffer-live-p buffer)
|
|
(with-current-buffer buffer
|
|
(let ((inhibit-read-only t)
|
|
(msg-pos (meshmonitor-chat--find-request-id-pos reply-id)))
|
|
(when msg-pos
|
|
(save-excursion
|
|
;; Move past the whole anchor message, not just one line, so
|
|
;; multi-line messages are never split by the reaction line.
|
|
(goto-char (or (next-single-property-change
|
|
msg-pos 'meshmonitor-chat-request-id)
|
|
(point-max)))
|
|
(let ((start (point))
|
|
(reply-ids (list reply-id)))
|
|
;; Collect reply-ids from every adjacent reaction line. The
|
|
;; scan is bounded by the reaction property itself, not by the
|
|
;; prompt marker: when the anchor is the last message the line
|
|
;; sits at the prompt boundary, and a marker-based limit would
|
|
;; miss it and stack a duplicate instead of merging.
|
|
(while (get-text-property
|
|
(point) 'meshmonitor-chat-reaction-for)
|
|
(let ((rid (get-text-property
|
|
(point) 'meshmonitor-chat-reaction-for)))
|
|
(unless (member rid reply-ids)
|
|
(push rid reply-ids)))
|
|
(forward-line 1))
|
|
(delete-region start (point))
|
|
;; Merge reactions from every collected bucket, deduped.
|
|
(let ((seen (make-hash-table :test 'equal))
|
|
(merged '()))
|
|
(dolist (rid reply-ids)
|
|
(dolist (r (gethash
|
|
rid meshmonitor-chat--reactions))
|
|
(unless (gethash r seen)
|
|
(puthash r t seen)
|
|
(push r merged))))
|
|
(when merged
|
|
(goto-char start)
|
|
;; Keep the reaction line inside the message area: when it
|
|
;; is inserted exactly at the prompt, advance the marker so
|
|
;; the prompt stays below it.
|
|
(set-marker-insertion-type
|
|
meshmonitor-chat--prompt-start t)
|
|
(insert
|
|
(propertize
|
|
(format " ↳ %s\n"
|
|
(mapconcat
|
|
(lambda (r)
|
|
(format "%s %s" (car r) (cdr r)))
|
|
(nreverse merged) ", "))
|
|
'face 'meshmonitor-chat-system-face
|
|
'read-only t
|
|
'rear-nonsticky t
|
|
'front-sticky t
|
|
'meshmonitor-chat-reaction-for reply-id))
|
|
(set-marker-insertion-type
|
|
meshmonitor-chat--prompt-start nil))))))))))
|
|
|
|
(defun meshmonitor-chat--mark-seen (buffer id req-id unix-ts)
|
|
"Mark message as seen in BUFFER and update last timestamp to UNIX-TS.
|
|
ID is the message id (unstable across lifecycle). REQ-ID is the
|
|
stable Meshtastic request id, tracked under a namespaced key so the
|
|
poll can dedup even when the id string format changes."
|
|
(with-current-buffer buffer
|
|
(when id
|
|
(puthash id t meshmonitor-chat--seen-ids))
|
|
(when req-id
|
|
(puthash (cons 'req req-id) t meshmonitor-chat--seen-ids))
|
|
(when (or (null meshmonitor-chat--last-timestamp)
|
|
(> unix-ts meshmonitor-chat--last-timestamp))
|
|
(setq meshmonitor-chat--last-timestamp unix-ts))))
|
|
|
|
(defun meshmonitor-chat--render-messages (buffer messages)
|
|
"Render MESSAGES into BUFFER with deduplication.
|
|
MESSAGES is a list of message alists from the API."
|
|
(when (and (buffer-live-p buffer) messages)
|
|
(let* ((text-msgs (seq-filter #'meshmonitor-chat--text-message-p
|
|
(copy-sequence messages)))
|
|
(sorted (sort text-msgs
|
|
(lambda (a b)
|
|
(< (meshmonitor-chat--parse-timestamp
|
|
(alist-get 'timestamp a))
|
|
(meshmonitor-chat--parse-timestamp
|
|
(alist-get 'timestamp b))))))
|
|
(regular (seq-remove #'meshmonitor-chat--reaction-p
|
|
sorted))
|
|
(reactions (seq-filter #'meshmonitor-chat--reaction-p
|
|
sorted)))
|
|
;; Render regular messages.
|
|
(dolist (msg regular)
|
|
(let ((id (alist-get 'id msg))
|
|
(req-id (meshmonitor-chat--extract-request-id msg)))
|
|
;; Always check delivery, even for already-seen messages.
|
|
(meshmonitor-chat--check-delivery msg buffer)
|
|
(unless (with-current-buffer buffer
|
|
(or (and id (gethash id
|
|
meshmonitor-chat--seen-ids))
|
|
(and req-id
|
|
(gethash (cons 'req req-id)
|
|
meshmonitor-chat--seen-ids))))
|
|
(let* ((from (or (alist-get 'fromNodeId msg)
|
|
(alist-get 'from msg)))
|
|
(sender (meshmonitor-chat--node-name from))
|
|
(text (or (alist-get 'text msg) ""))
|
|
(ts (alist-get 'timestamp msg))
|
|
(selfp (meshmonitor-chat--is-self-p msg))
|
|
(unix-ts (meshmonitor-chat--parse-timestamp ts))
|
|
(reply-id (alist-get 'replyId msg))
|
|
(reply-name (meshmonitor-chat--find-reply-sender
|
|
buffer reply-id))
|
|
(delivery (when selfp
|
|
(meshmonitor-chat--msg-delivery-state
|
|
msg))))
|
|
(if (and selfp delivery)
|
|
(meshmonitor-chat--insert-sent-msg
|
|
buffer ts sender text delivery req-id)
|
|
(meshmonitor-chat--insert-msg
|
|
buffer ts sender text selfp nil req-id from
|
|
reply-name)
|
|
(unless (or selfp (get-buffer-window buffer))
|
|
(with-current-buffer buffer
|
|
(meshmonitor-chat--notify
|
|
sender text
|
|
meshmonitor-chat--target-type
|
|
meshmonitor-chat--target))))
|
|
(meshmonitor-chat--mark-seen
|
|
buffer id req-id unix-ts)))))
|
|
;; Process emoji reactions.
|
|
(dolist (msg reactions)
|
|
(let ((id (alist-get 'id msg))
|
|
(reply-id (alist-get 'replyId msg))
|
|
(emoji-text (or (alist-get 'text msg) ""))
|
|
(from (or (alist-get 'fromNodeId msg)
|
|
(alist-get 'from msg)))
|
|
(unix-ts (meshmonitor-chat--parse-timestamp
|
|
(alist-get 'timestamp msg))))
|
|
(unless (and id (with-current-buffer buffer
|
|
(gethash id meshmonitor-chat--seen-ids)))
|
|
(when reply-id
|
|
(with-current-buffer buffer
|
|
(let ((existing (gethash reply-id
|
|
meshmonitor-chat--reactions)))
|
|
(push (cons emoji-text
|
|
(meshmonitor-chat--node-name from))
|
|
existing)
|
|
(puthash reply-id existing
|
|
meshmonitor-chat--reactions)))
|
|
(meshmonitor-chat--render-reaction-line
|
|
buffer reply-id))
|
|
(meshmonitor-chat--mark-seen
|
|
buffer id nil unix-ts))))
|
|
;; Mark conversation as read when buffer is visible.
|
|
(when (get-buffer-window buffer)
|
|
(with-current-buffer buffer
|
|
(puthash (cons meshmonitor-chat--target-type
|
|
meshmonitor-chat--target)
|
|
(or meshmonitor-chat--last-timestamp 0)
|
|
meshmonitor-chat--read-timestamps))))))
|
|
|
|
;;;; Input handling
|
|
|
|
(defun meshmonitor-chat-send-input ()
|
|
"Send the text after the prompt as a message."
|
|
(interactive)
|
|
(if (< (point) meshmonitor-chat--prompt-end)
|
|
(goto-char (point-max))
|
|
(let ((input (buffer-substring-no-properties
|
|
meshmonitor-chat--prompt-end (point-max))))
|
|
(when (string-blank-p input)
|
|
(user-error "Empty message"))
|
|
(ring-insert meshmonitor-chat--input-ring input)
|
|
(setq meshmonitor-chat--input-ring-index 0)
|
|
(let ((inhibit-read-only t))
|
|
(delete-region meshmonitor-chat--prompt-end (point-max)))
|
|
(meshmonitor-chat--send-text input))))
|
|
|
|
(defun meshmonitor-chat--send-text (text)
|
|
"Send TEXT to the current buffer target."
|
|
(unless meshmonitor-chat--connected
|
|
(user-error "Not connected to MeshMonitor"))
|
|
(let ((buf (current-buffer))
|
|
(target meshmonitor-chat--target)
|
|
(ttype meshmonitor-chat--target-type)
|
|
(reply-id (car meshmonitor-chat--reply-to)))
|
|
(when meshmonitor-chat--reply-to
|
|
(setq meshmonitor-chat--reply-to nil)
|
|
(meshmonitor-chat--refresh-prompt))
|
|
(let ((cb (lambda (result)
|
|
(let ((status (if result (car result) 0))
|
|
(data (alist-get 'data (cdr result))))
|
|
(cond
|
|
((and result (< status 400))
|
|
(let ((req-id (alist-get 'requestId data))
|
|
(msg-id (alist-get 'messageId data))
|
|
(parts (or (alist-get 'messageCount data)
|
|
1))
|
|
(sender
|
|
(meshmonitor-chat--node-name
|
|
(or meshmonitor-chat--my-node-id "me"))))
|
|
(when (buffer-live-p buf)
|
|
(unless (with-current-buffer buf
|
|
(or (and msg-id
|
|
(gethash
|
|
msg-id
|
|
meshmonitor-chat--seen-ids))
|
|
(and req-id
|
|
(gethash
|
|
(cons 'req req-id)
|
|
meshmonitor-chat--seen-ids))))
|
|
(meshmonitor-chat--insert-sent-msg
|
|
buf (float-time) sender text
|
|
'pending req-id))
|
|
(when msg-id
|
|
(with-current-buffer buf
|
|
(puthash msg-id t
|
|
meshmonitor-chat--seen-ids)))
|
|
(when req-id
|
|
(with-current-buffer buf
|
|
(puthash (cons 'req req-id) t
|
|
meshmonitor-chat--seen-ids))))
|
|
(when (> parts 1)
|
|
(meshmonitor-chat--insert-msg
|
|
buf nil nil
|
|
(format "Message split into %d parts"
|
|
parts)
|
|
nil t))))
|
|
((and result (= status 413))
|
|
(meshmonitor-chat--insert-msg
|
|
buf nil nil
|
|
"Message too long (max ~600 bytes, 3 parts)"
|
|
nil t))
|
|
((and result (= status 503))
|
|
(meshmonitor-chat--insert-msg
|
|
buf nil nil
|
|
"Meshtastic node not connected" nil t))
|
|
(t
|
|
(meshmonitor-chat--insert-msg
|
|
buf nil nil
|
|
(format "Send failed (HTTP %s)"
|
|
(or status "timeout"))
|
|
nil t)))))))
|
|
(pcase ttype
|
|
('channel
|
|
(meshmonitor-chat--api-send text target nil reply-id cb))
|
|
('dm
|
|
(meshmonitor-chat--api-send text nil target reply-id cb))
|
|
(_ (user-error "No target set for this buffer"))))))
|
|
|
|
(defun meshmonitor-chat--navigate-input (direction)
|
|
"Navigate input history in DIRECTION (1 for previous, -1 for next)."
|
|
(when (> (ring-length meshmonitor-chat--input-ring) 0)
|
|
(let ((inhibit-read-only t))
|
|
(delete-region meshmonitor-chat--prompt-end (point-max))
|
|
(when (= direction -1)
|
|
(setq meshmonitor-chat--input-ring-index
|
|
(mod (1- meshmonitor-chat--input-ring-index)
|
|
(ring-length meshmonitor-chat--input-ring))))
|
|
(insert (ring-ref meshmonitor-chat--input-ring
|
|
meshmonitor-chat--input-ring-index))
|
|
(when (= direction 1)
|
|
(setq meshmonitor-chat--input-ring-index
|
|
(mod (1+ meshmonitor-chat--input-ring-index)
|
|
(ring-length meshmonitor-chat--input-ring)))))))
|
|
|
|
(defun meshmonitor-chat-previous-input ()
|
|
"Replace current input with previous entry from history."
|
|
(interactive)
|
|
(meshmonitor-chat--navigate-input 1))
|
|
|
|
(defun meshmonitor-chat-next-input ()
|
|
"Replace current input with next entry from history."
|
|
(interactive)
|
|
(meshmonitor-chat--navigate-input -1))
|
|
|
|
(defun meshmonitor-chat--get-msg-property-at-line (prop)
|
|
"Get text property PROP from the current line."
|
|
(let ((value nil)
|
|
(start (line-beginning-position))
|
|
(end (line-end-position)))
|
|
(save-excursion
|
|
(goto-char start)
|
|
(while (and (not value) (< (point) end))
|
|
(setq value (get-text-property (point) prop))
|
|
(goto-char (or (next-single-property-change
|
|
(point) prop nil end)
|
|
end))))
|
|
value))
|
|
|
|
(defun meshmonitor-chat-resend ()
|
|
"Resend the message at point."
|
|
(interactive)
|
|
(let ((text (meshmonitor-chat--get-msg-property-at-line
|
|
'meshmonitor-chat-msg-text)))
|
|
(if text
|
|
(progn
|
|
(goto-char meshmonitor-chat--prompt-end)
|
|
(meshmonitor-chat--send-text text))
|
|
(user-error "No sent message at point"))))
|
|
|
|
(defun meshmonitor-chat-reply ()
|
|
"Set reply context to the message at point.
|
|
The next sent message will be a reply to this one."
|
|
(interactive)
|
|
(let ((req-id (meshmonitor-chat--get-msg-property-at-line
|
|
'meshmonitor-chat-request-id))
|
|
(sender (meshmonitor-chat--get-msg-property-at-line
|
|
'meshmonitor-chat-sender)))
|
|
(if req-id
|
|
(progn
|
|
(setq meshmonitor-chat--reply-to (cons req-id sender))
|
|
(meshmonitor-chat--refresh-prompt)
|
|
(goto-char (point-max))
|
|
(message "Replying to %s (C-c C-k to cancel)" sender))
|
|
(user-error "No message at point"))))
|
|
|
|
(defun meshmonitor-chat-cancel-reply ()
|
|
"Cancel the current reply context."
|
|
(interactive)
|
|
(setq meshmonitor-chat--reply-to nil)
|
|
(meshmonitor-chat--refresh-prompt)
|
|
(message "Reply cancelled"))
|
|
|
|
(declare-function emojify-completing-read "emojify" (&optional prompt))
|
|
|
|
(defun meshmonitor-chat-react ()
|
|
"React with an emoji to the message at point.
|
|
Uses `emojify-completing-read' when available for emoji selection.
|
|
Shows the reaction immediately and sends it via the API."
|
|
(interactive)
|
|
(let ((req-id (meshmonitor-chat--get-msg-property-at-line
|
|
'meshmonitor-chat-request-id)))
|
|
(if req-id
|
|
(let ((emoji (if (fboundp 'emojify-completing-read)
|
|
(emojify-completing-read "Reaction: ")
|
|
(read-string "Emoji: "))))
|
|
(when (and emoji (not (string-empty-p emoji)))
|
|
(let ((buf (current-buffer))
|
|
(target meshmonitor-chat--target)
|
|
(ttype meshmonitor-chat--target-type)
|
|
(sender (meshmonitor-chat--node-name
|
|
(or meshmonitor-chat--my-node-id "me")))
|
|
(reaction (cons emoji nil)))
|
|
;; Optimistic: show reaction immediately.
|
|
(setcdr reaction sender)
|
|
(let ((existing (gethash req-id
|
|
meshmonitor-chat--reactions)))
|
|
(push reaction existing)
|
|
(puthash req-id existing
|
|
meshmonitor-chat--reactions))
|
|
(meshmonitor-chat--render-reaction-line buf req-id)
|
|
;; Send via API.
|
|
(meshmonitor-chat--api-send
|
|
emoji
|
|
(when (eq ttype 'channel) target)
|
|
(when (eq ttype 'dm) target)
|
|
req-id
|
|
(lambda (result)
|
|
(if (and result (< (car result) 400))
|
|
;; Mark as seen to prevent polling duplicate.
|
|
(let ((msg-id
|
|
(alist-get
|
|
'messageId
|
|
(alist-get 'data (cdr result)))))
|
|
(when (and msg-id (buffer-live-p buf))
|
|
(with-current-buffer buf
|
|
(puthash msg-id t
|
|
meshmonitor-chat--seen-ids))))
|
|
;; On error, remove the optimistic reaction.
|
|
(when (buffer-live-p buf)
|
|
(with-current-buffer buf
|
|
(let ((rs (gethash req-id
|
|
meshmonitor-chat--reactions)))
|
|
(puthash req-id (delete reaction rs)
|
|
meshmonitor-chat--reactions))
|
|
(meshmonitor-chat--render-reaction-line
|
|
buf req-id))
|
|
(meshmonitor-chat--insert-msg
|
|
buf nil nil "Reaction failed" nil t))))))))
|
|
(user-error "No message at point"))))
|
|
|
|
(defun meshmonitor-chat-dm-at-point ()
|
|
"Open a DM chat with the sender of the message at point."
|
|
(interactive)
|
|
(let ((from-id (meshmonitor-chat--get-msg-property-at-line
|
|
'meshmonitor-chat-from-id)))
|
|
(if from-id
|
|
(meshmonitor-chat-open-dm from-id)
|
|
(user-error "No message at point"))))
|
|
|
|
(defun meshmonitor-chat-refresh-chat ()
|
|
"Reload message history for the current chat buffer."
|
|
(interactive)
|
|
(unless meshmonitor-chat--target
|
|
(user-error "Not a chat buffer"))
|
|
(let ((buf (current-buffer)))
|
|
(pcase meshmonitor-chat--target-type
|
|
('channel
|
|
(meshmonitor-chat--api-messages
|
|
`((channel . ,meshmonitor-chat--target)
|
|
(limit . ,meshmonitor-chat-message-limit))
|
|
(lambda (result)
|
|
(when result
|
|
(let ((msgs (alist-get 'data (cdr result))))
|
|
(when msgs
|
|
(meshmonitor-chat--render-messages buf msgs)))))))
|
|
('dm
|
|
(meshmonitor-chat--fetch-dm-both-directions
|
|
meshmonitor-chat--target meshmonitor-chat-message-limit
|
|
(lambda (msgs)
|
|
(when msgs
|
|
(meshmonitor-chat--render-messages buf msgs))))))
|
|
(message "MeshMonitor: refreshed")))
|
|
|
|
;;;; Buffer management
|
|
|
|
(defun meshmonitor-chat--buffer-name (ttype target)
|
|
"Generate buffer name for target type TTYPE and TARGET."
|
|
(pcase ttype
|
|
('channel (format "*MeshMonitor: #%s*"
|
|
(meshmonitor-chat--channel-name target)))
|
|
('dm (format "*MeshMonitor: DM %s*"
|
|
(meshmonitor-chat--node-name target)))))
|
|
|
|
(defun meshmonitor-chat--get-or-create-buffer (ttype target)
|
|
"Get or create a chat buffer for TTYPE and TARGET."
|
|
(let* ((name (meshmonitor-chat--buffer-name ttype target))
|
|
(buf (get-buffer name)))
|
|
(if (and buf (buffer-live-p buf))
|
|
buf
|
|
(setq buf (get-buffer-create name))
|
|
(with-current-buffer buf
|
|
(meshmonitor-chat-mode)
|
|
(setq meshmonitor-chat--target target)
|
|
(setq meshmonitor-chat--target-type ttype)
|
|
(meshmonitor-chat--setup-prompt))
|
|
(push (cons (cons ttype target) buf)
|
|
meshmonitor-chat--chat-buffers)
|
|
buf)))
|
|
|
|
;;;; Channel list mode
|
|
|
|
(defun meshmonitor-chat-channel-list-open-by-number ()
|
|
"Open channel by the digit key pressed (0-7)."
|
|
(interactive)
|
|
(let ((id (- last-command-event ?0)))
|
|
(meshmonitor-chat-open-channel id)))
|
|
|
|
(defvar meshmonitor-chat-channel-list-mode-map
|
|
(let ((map (make-sparse-keymap)))
|
|
(set-keymap-parent map tabulated-list-mode-map)
|
|
(define-key map (kbd "RET")
|
|
#'meshmonitor-chat-channel-list-open)
|
|
(define-key map (kbd "g")
|
|
#'meshmonitor-chat-channel-list-refresh)
|
|
(dotimes (i 8)
|
|
(define-key map (kbd (number-to-string i))
|
|
#'meshmonitor-chat-channel-list-open-by-number))
|
|
map)
|
|
"Keymap for `meshmonitor-chat-channel-list-mode'.")
|
|
|
|
(define-derived-mode meshmonitor-chat-channel-list-mode
|
|
tabulated-list-mode "MeshChannels"
|
|
"Major mode for listing MeshMonitor channels."
|
|
:group 'meshmonitor-chat
|
|
(setq tabulated-list-format
|
|
[("ID" 4 t)
|
|
("Name" 20 t)
|
|
("Role" 12 t)])
|
|
(setq tabulated-list-padding 2)
|
|
(tabulated-list-init-header))
|
|
|
|
(defun meshmonitor-chat-channel-list-open ()
|
|
"Open the channel at point."
|
|
(interactive)
|
|
(let ((entry (tabulated-list-get-entry)))
|
|
(when entry
|
|
(meshmonitor-chat-open-channel
|
|
(string-to-number (aref entry 0))))))
|
|
|
|
(defun meshmonitor-chat-channel-list-refresh ()
|
|
"Refresh the channel list from the server."
|
|
(interactive)
|
|
(meshmonitor-chat--fetch-channels
|
|
(lambda (_channels)
|
|
(let ((buf (get-buffer "*MeshMonitor: Channels*")))
|
|
(when (buffer-live-p buf)
|
|
(with-current-buffer buf
|
|
(meshmonitor-chat--populate-channel-list)))))))
|
|
|
|
(defun meshmonitor-chat--populate-channel-list ()
|
|
"Populate the current buffer with cached channel data."
|
|
(let ((role-names '((0 . "Disabled")
|
|
(1 . "Primary")
|
|
(2 . "Secondary"))))
|
|
(setq tabulated-list-entries
|
|
(mapcar
|
|
(lambda (ch)
|
|
(let ((id (alist-get 'id ch))
|
|
(name (or (alist-get 'name ch) ""))
|
|
(role (or (alist-get 'role ch) 0)))
|
|
(list id
|
|
(vector (number-to-string id)
|
|
name
|
|
(or (cdr (assq role role-names))
|
|
(format "%d" role))))))
|
|
(seq-filter
|
|
(lambda (ch) (> (or (alist-get 'role ch) 0) 0))
|
|
meshmonitor-chat--channels)))
|
|
(tabulated-list-print t)))
|
|
|
|
;;;; DM list mode
|
|
|
|
(defvar meshmonitor-chat-dm-list-mode-map
|
|
(let ((map (make-sparse-keymap)))
|
|
(set-keymap-parent map tabulated-list-mode-map)
|
|
(define-key map (kbd "RET")
|
|
#'meshmonitor-chat-dm-list-open)
|
|
(define-key map (kbd "g")
|
|
#'meshmonitor-chat-dm-list-refresh)
|
|
map)
|
|
"Keymap for `meshmonitor-chat-dm-list-mode'.")
|
|
|
|
(define-derived-mode meshmonitor-chat-dm-list-mode
|
|
tabulated-list-mode "MeshDMs"
|
|
"Major mode for listing MeshMonitor DM conversations."
|
|
:group 'meshmonitor-chat
|
|
(setq tabulated-list-format
|
|
[("Name" 20 t)
|
|
("Node ID" 14 t)
|
|
("Last message" 40 nil)])
|
|
(setq tabulated-list-padding 2)
|
|
(tabulated-list-init-header))
|
|
|
|
(defun meshmonitor-chat-dm-list-open ()
|
|
"Open DM chat with the node at point."
|
|
(interactive)
|
|
(let ((entry (tabulated-list-get-entry)))
|
|
(when entry
|
|
(meshmonitor-chat-open-dm (aref entry 1)))))
|
|
|
|
(defun meshmonitor-chat-dm-list-refresh ()
|
|
"Refresh the DM conversation list from the server."
|
|
(interactive)
|
|
(meshmonitor-chat--fetch-dm-conversations
|
|
(lambda (partners)
|
|
(let ((buf (get-buffer "*MeshMonitor: Direct Messages*")))
|
|
(when (buffer-live-p buf)
|
|
(with-current-buffer buf
|
|
(meshmonitor-chat--populate-dm-list partners)))))))
|
|
|
|
(defun meshmonitor-chat--fetch-dm-conversations (callback)
|
|
"Fetch DM conversations and call CALLBACK with partner alist.
|
|
Each element is (NODE-ID . LAST-MESSAGE-ALIST)."
|
|
(meshmonitor-chat--api-messages
|
|
`((limit . 200))
|
|
(lambda (result)
|
|
(let ((partners nil))
|
|
(when result
|
|
(let* ((msgs (alist-get 'data (cdr result)))
|
|
(dm-msgs (seq-filter
|
|
(lambda (m)
|
|
(and (equal (alist-get 'channel m) -1)
|
|
(meshmonitor-chat--text-message-p m)
|
|
(not (meshmonitor-chat--reaction-p
|
|
m))))
|
|
msgs)))
|
|
(setq partners
|
|
(meshmonitor-chat--extract-dm-partners
|
|
dm-msgs))))
|
|
(funcall callback partners)))))
|
|
|
|
(defun meshmonitor-chat--extract-dm-partners (messages)
|
|
"Extract unique DM conversation partners from MESSAGES.
|
|
Return alist of (NODE-ID . LAST-MESSAGE-ALIST)."
|
|
(let ((partners (make-hash-table :test 'equal)))
|
|
(dolist (msg messages)
|
|
(let ((from (alist-get 'fromNodeId msg))
|
|
(to (alist-get 'toNodeId msg))
|
|
(ts (meshmonitor-chat--parse-timestamp
|
|
(alist-get 'timestamp msg))))
|
|
(let ((partner
|
|
(cond
|
|
((and meshmonitor-chat--my-node-id
|
|
(equal from meshmonitor-chat--my-node-id))
|
|
to)
|
|
((and meshmonitor-chat--my-node-id
|
|
(equal to meshmonitor-chat--my-node-id))
|
|
from)
|
|
(t from))))
|
|
(when (and partner
|
|
(not (equal partner "broadcast"))
|
|
(not (equal partner
|
|
meshmonitor-chat--my-node-id)))
|
|
(let ((existing (gethash partner partners)))
|
|
(when (or (null existing)
|
|
(> ts (meshmonitor-chat--parse-timestamp
|
|
(alist-get 'timestamp existing))))
|
|
(puthash partner msg partners)))))))
|
|
(let ((result nil))
|
|
(maphash (lambda (k v) (push (cons k v) result)) partners)
|
|
result)))
|
|
|
|
(defun meshmonitor-chat--populate-dm-list (partners)
|
|
"Populate the current buffer with DM PARTNERS alist."
|
|
(setq tabulated-list-entries
|
|
(mapcar
|
|
(lambda (entry)
|
|
(let* ((node-id (car entry))
|
|
(msg (cdr entry))
|
|
(name (meshmonitor-chat--node-name node-id))
|
|
(text (or (alist-get 'text msg) "")))
|
|
(list node-id
|
|
(vector name
|
|
node-id
|
|
(truncate-string-to-width
|
|
text 40 nil nil "...")))))
|
|
partners))
|
|
(tabulated-list-print t))
|
|
|
|
;;;; Node list mode
|
|
|
|
(defvar meshmonitor-chat-node-list-mode-map
|
|
(let ((map (make-sparse-keymap)))
|
|
(set-keymap-parent map tabulated-list-mode-map)
|
|
(define-key map (kbd "RET")
|
|
#'meshmonitor-chat-node-list-open)
|
|
(define-key map (kbd "g")
|
|
#'meshmonitor-chat-node-list-refresh)
|
|
(define-key map (kbd "t")
|
|
#'meshmonitor-chat-traceroute)
|
|
(define-key map (kbd "p")
|
|
#'meshmonitor-chat-request-position)
|
|
map)
|
|
"Keymap for `meshmonitor-chat-node-list-mode'.")
|
|
|
|
(define-derived-mode meshmonitor-chat-node-list-mode
|
|
tabulated-list-mode "MeshNodes"
|
|
"Major mode for listing MeshMonitor nodes sorted by hops."
|
|
:group 'meshmonitor-chat
|
|
(setq tabulated-list-format
|
|
[("Hops" 5 meshmonitor-chat--sort-by-hops)
|
|
("Name" 30 t)
|
|
("Node ID" 14 t)
|
|
("Last heard" 12 t)])
|
|
(setq tabulated-list-padding 2)
|
|
(setq tabulated-list-sort-key '("Hops"))
|
|
(tabulated-list-init-header))
|
|
|
|
(defun meshmonitor-chat--sort-by-hops (a b)
|
|
"Sort entries A and B by hop count numerically."
|
|
(let ((ha (string-to-number (aref (cadr a) 0)))
|
|
(hb (string-to-number (aref (cadr b) 0))))
|
|
(< ha hb)))
|
|
|
|
(defun meshmonitor-chat-node-list-open ()
|
|
"Open DM chat with the node at point.
|
|
Refuses if the node has not exchanged encryption keys."
|
|
(interactive)
|
|
(let ((entry (tabulated-list-get-entry)))
|
|
(when entry
|
|
(let ((node-id (aref entry 2)))
|
|
(if (meshmonitor-chat--node-has-pkc-p node-id)
|
|
(meshmonitor-chat-open-dm node-id)
|
|
(user-error "Node %s has no key exchange, DM not possible"
|
|
(meshmonitor-chat--node-name node-id)))))))
|
|
|
|
(defun meshmonitor-chat-node-list-refresh ()
|
|
"Refresh the node list from the server."
|
|
(interactive)
|
|
(meshmonitor-chat--fetch-nodes
|
|
(lambda ()
|
|
(let ((buf (get-buffer "*MeshMonitor: Nodes*")))
|
|
(when (buffer-live-p buf)
|
|
(with-current-buffer buf
|
|
(meshmonitor-chat--populate-node-list)))))))
|
|
|
|
(defun meshmonitor-chat--format-last-heard (ts)
|
|
"Format last heard timestamp TS as relative time."
|
|
(if (and ts (numberp ts) (> ts 0))
|
|
(let* ((secs (- (float-time) (if (> ts 9999999999)
|
|
(/ ts 1000) ts)))
|
|
(mins (/ secs 60))
|
|
(hours (/ mins 60))
|
|
(days (/ hours 24)))
|
|
(cond
|
|
((< mins 1) "now")
|
|
((< mins 60) (format "%dm" (truncate mins)))
|
|
((< hours 24) (format "%dh" (truncate hours)))
|
|
(t (format "%dd" (truncate days)))))
|
|
"?"))
|
|
|
|
(defvar meshmonitor-chat--online-threshold 900
|
|
"Seconds since last heard to consider a node online (default 15 min).")
|
|
|
|
(defun meshmonitor-chat--node-online-p (last-heard)
|
|
"Return non-nil if LAST-HEARD timestamp is recent enough."
|
|
(and last-heard (numberp last-heard) (> last-heard 0)
|
|
(< (- (float-time) (if (> last-heard 9999999999)
|
|
(/ last-heard 1000) last-heard))
|
|
meshmonitor-chat--online-threshold)))
|
|
|
|
(defun meshmonitor-chat--populate-node-list ()
|
|
"Populate the current buffer with cached node data."
|
|
(let ((entries nil))
|
|
(maphash
|
|
(lambda (key node)
|
|
(when (string-prefix-p "!" key)
|
|
(let* ((hops (or (alist-get 'hopsAway node) 99))
|
|
(name (or (alist-get 'longName node) ""))
|
|
(last-heard (alist-get 'lastHeard node))
|
|
(online (meshmonitor-chat--node-online-p
|
|
last-heard))
|
|
(pkc (meshmonitor-chat--json-true-p
|
|
(alist-get 'hasPKC node)))
|
|
(indicator (concat (if online "🟢" "⚫")
|
|
(if pkc " 🔑" ""))))
|
|
(push (list key
|
|
(vector (number-to-string hops)
|
|
(format "%s %s" indicator name)
|
|
key
|
|
(meshmonitor-chat--format-last-heard
|
|
last-heard)))
|
|
entries))))
|
|
meshmonitor-chat--nodes)
|
|
(setq tabulated-list-entries entries)
|
|
(tabulated-list-print t)))
|
|
|
|
;;;; Unread messages mode
|
|
|
|
(defvar meshmonitor-chat-unread-list-mode-map
|
|
(let ((map (make-sparse-keymap)))
|
|
(set-keymap-parent map tabulated-list-mode-map)
|
|
(define-key map (kbd "RET")
|
|
#'meshmonitor-chat-unread-list-open)
|
|
(define-key map (kbd "g")
|
|
#'meshmonitor-chat-unread-list-refresh)
|
|
map)
|
|
"Keymap for `meshmonitor-chat-unread-list-mode'.")
|
|
|
|
(define-derived-mode meshmonitor-chat-unread-list-mode
|
|
tabulated-list-mode "MeshUnread"
|
|
"Major mode for listing nodes with unread messages."
|
|
:group 'meshmonitor-chat
|
|
(setq tabulated-list-format
|
|
[("Unread" 7 meshmonitor-chat--sort-by-unread)
|
|
("Name" 25 t)
|
|
("Node ID" 14 t)
|
|
("Last message" 40 nil)])
|
|
(setq tabulated-list-padding 2)
|
|
(setq tabulated-list-sort-key '("Unread" . t))
|
|
(tabulated-list-init-header))
|
|
|
|
(defun meshmonitor-chat--sort-by-unread (a b)
|
|
"Sort entries A and B by unread count numerically."
|
|
(let ((ua (string-to-number (aref (cadr a) 0)))
|
|
(ub (string-to-number (aref (cadr b) 0))))
|
|
(< ua ub)))
|
|
|
|
(defun meshmonitor-chat-unread-list-open ()
|
|
"Open DM chat with the node at point."
|
|
(interactive)
|
|
(let ((entry (tabulated-list-get-entry)))
|
|
(when entry
|
|
(meshmonitor-chat-open-dm (aref entry 2)))))
|
|
|
|
(defun meshmonitor-chat-unread-list-refresh ()
|
|
"Refresh the unread messages list from the server."
|
|
(interactive)
|
|
(meshmonitor-chat--fetch-unread
|
|
(lambda (unread)
|
|
(let ((buf (get-buffer "*MeshMonitor: Unread*")))
|
|
(when (buffer-live-p buf)
|
|
(with-current-buffer buf
|
|
(meshmonitor-chat--populate-unread-list unread)))))))
|
|
|
|
(defun meshmonitor-chat--fetch-unread (callback)
|
|
"Fetch messages and find unread conversations.
|
|
Call CALLBACK with alist of (NODE-ID COUNT LAST-MSG)."
|
|
(meshmonitor-chat--api-messages
|
|
`((limit . 200))
|
|
(lambda (result)
|
|
(let ((unread nil))
|
|
(when result
|
|
(let ((msgs (alist-get 'data (cdr result))))
|
|
(when msgs
|
|
(let ((by-node (make-hash-table :test 'equal)))
|
|
(dolist (msg msgs)
|
|
(let* ((from (alist-get 'fromNodeId msg))
|
|
(ts (meshmonitor-chat--parse-timestamp
|
|
(alist-get 'timestamp msg)))
|
|
(is-dm (and (equal (alist-get 'channel msg)
|
|
-1)
|
|
(meshmonitor-chat--text-message-p
|
|
msg)
|
|
(not (meshmonitor-chat--reaction-p
|
|
msg))))
|
|
(is-self (meshmonitor-chat--is-self-p msg)))
|
|
(when (and is-dm (not is-self) from)
|
|
(let ((read-ts
|
|
(or (gethash (cons 'dm from)
|
|
meshmonitor-chat--read-timestamps)
|
|
0)))
|
|
(when (> ts read-ts)
|
|
(let ((entry (gethash from by-node)))
|
|
(if entry
|
|
(progn
|
|
(cl-incf (car entry))
|
|
(when (> ts
|
|
(meshmonitor-chat--parse-timestamp
|
|
(alist-get 'timestamp
|
|
(cdr entry))))
|
|
(setcdr entry msg)))
|
|
(puthash from (cons 1 msg)
|
|
by-node))))))))
|
|
(maphash (lambda (k v)
|
|
(push (list k (car v) (cdr v)) unread))
|
|
by-node)))))
|
|
(funcall callback unread)))))
|
|
|
|
(defun meshmonitor-chat--populate-unread-list (unread)
|
|
"Populate current buffer with UNREAD conversation data.
|
|
Each element of UNREAD is (NODE-ID COUNT LAST-MSG)."
|
|
(setq tabulated-list-entries
|
|
(mapcar
|
|
(lambda (entry)
|
|
(let* ((node-id (nth 0 entry))
|
|
(count (nth 1 entry))
|
|
(msg (nth 2 entry))
|
|
(name (meshmonitor-chat--node-name node-id))
|
|
(text (or (alist-get 'text msg) "")))
|
|
(list node-id
|
|
(vector (number-to-string count)
|
|
name
|
|
node-id
|
|
(truncate-string-to-width
|
|
text 40 nil nil "...")))))
|
|
unread))
|
|
(tabulated-list-print t))
|
|
|
|
;;;; Open chat buffers
|
|
|
|
(defun meshmonitor-chat-open-channel (channel-id)
|
|
"Open a chat buffer for CHANNEL-ID and load message history."
|
|
(meshmonitor-chat--ensure-connected)
|
|
(let ((buf (meshmonitor-chat--get-or-create-buffer
|
|
'channel channel-id)))
|
|
(switch-to-buffer buf)
|
|
(meshmonitor-chat--api-messages
|
|
`((channel . ,channel-id)
|
|
(limit . ,meshmonitor-chat-message-limit))
|
|
(lambda (result)
|
|
(when result
|
|
(let ((msgs (alist-get 'data (cdr result))))
|
|
(when msgs
|
|
(meshmonitor-chat--render-messages buf msgs))))))))
|
|
|
|
(defun meshmonitor-chat--fetch-dm-both-directions (node-id limit
|
|
callback)
|
|
"Fetch DM messages with NODE-ID in both directions.
|
|
LIMIT is the max messages per direction.
|
|
Call CALLBACK with the merged message list."
|
|
(let ((all-messages nil)
|
|
(pending 2))
|
|
(let ((handler
|
|
(lambda (result)
|
|
(when result
|
|
(let ((msgs (alist-get 'data (cdr result))))
|
|
(when msgs
|
|
(setq all-messages
|
|
(append msgs all-messages)))))
|
|
(setq pending (1- pending))
|
|
(when (zerop pending)
|
|
(funcall callback all-messages)))))
|
|
(meshmonitor-chat--api-messages
|
|
`((toNodeId . ,node-id) (limit . ,limit)) handler)
|
|
(meshmonitor-chat--api-messages
|
|
`((fromNodeId . ,node-id) (limit . ,limit)) handler))))
|
|
|
|
(defun meshmonitor-chat-open-dm (node-id)
|
|
"Open a DM chat buffer with NODE-ID and load history."
|
|
(meshmonitor-chat--ensure-connected)
|
|
(let ((buf (meshmonitor-chat--get-or-create-buffer 'dm node-id)))
|
|
(switch-to-buffer buf)
|
|
(meshmonitor-chat--fetch-dm-both-directions
|
|
node-id meshmonitor-chat-message-limit
|
|
(lambda (msgs)
|
|
(when msgs
|
|
(meshmonitor-chat--render-messages buf msgs))))))
|
|
|
|
;;;; Polling
|
|
|
|
(defun meshmonitor-chat--start-polling ()
|
|
"Start the periodic message polling timer."
|
|
(meshmonitor-chat--stop-polling)
|
|
(setq meshmonitor-chat--poll-timer
|
|
(run-with-timer
|
|
meshmonitor-chat-poll-interval
|
|
meshmonitor-chat-poll-interval
|
|
#'meshmonitor-chat--poll)))
|
|
|
|
(defun meshmonitor-chat--stop-polling ()
|
|
"Stop the periodic message polling timer."
|
|
(when meshmonitor-chat--poll-timer
|
|
(cancel-timer meshmonitor-chat--poll-timer)
|
|
(setq meshmonitor-chat--poll-timer nil)))
|
|
|
|
(defun meshmonitor-chat--poll ()
|
|
"Poll for new messages in all open chat buffers.
|
|
Detects gaps from sleep/suspend and does a full fetch if needed."
|
|
(when meshmonitor-chat--connected
|
|
(let* ((now (float-time))
|
|
(gap-p (and meshmonitor-chat--last-poll-time
|
|
(> (- now meshmonitor-chat--last-poll-time)
|
|
(* 2 meshmonitor-chat-poll-interval)))))
|
|
(setq meshmonitor-chat--last-poll-time now)
|
|
(setq meshmonitor-chat--chat-buffers
|
|
(seq-filter (lambda (e) (buffer-live-p (cdr e)))
|
|
meshmonitor-chat--chat-buffers))
|
|
(dolist (entry meshmonitor-chat--chat-buffers)
|
|
(let* ((key (car entry))
|
|
(buf (cdr entry))
|
|
(ttype (car key))
|
|
(target (cdr key))
|
|
(since (unless gap-p
|
|
(with-current-buffer buf
|
|
meshmonitor-chat--last-timestamp)))
|
|
(limit (if gap-p
|
|
meshmonitor-chat-message-limit 20))
|
|
(render-cb
|
|
(lambda (result)
|
|
(when result
|
|
(let ((msgs (alist-get 'data (cdr result))))
|
|
(when msgs
|
|
(meshmonitor-chat--render-messages
|
|
buf msgs)))))))
|
|
(pcase ttype
|
|
('channel
|
|
(let ((params `((channel . ,target)
|
|
(limit . ,limit))))
|
|
(when since
|
|
(push `(since . ,(1+ since)) params))
|
|
(meshmonitor-chat--api-messages params render-cb)))
|
|
('dm
|
|
(let ((params-from
|
|
`((fromNodeId . ,target) (limit . ,limit)))
|
|
(params-to
|
|
`((toNodeId . ,target) (limit . ,limit)))
|
|
(all-msgs nil)
|
|
(dm-pending 2))
|
|
(when since
|
|
(push `(since . ,(1+ since)) params-from)
|
|
(push `(since . ,(1+ since)) params-to))
|
|
(let ((dm-handler
|
|
(lambda (result)
|
|
(when result
|
|
(let ((msgs (alist-get 'data
|
|
(cdr result))))
|
|
(when msgs
|
|
(setq all-msgs
|
|
(append msgs all-msgs)))))
|
|
(setq dm-pending (1- dm-pending))
|
|
(when (zerop dm-pending)
|
|
(when all-msgs
|
|
(meshmonitor-chat--render-messages
|
|
buf all-msgs))))))
|
|
(meshmonitor-chat--api-messages
|
|
params-from dm-handler)
|
|
(meshmonitor-chat--api-messages
|
|
params-to dm-handler))))))))))
|
|
|
|
;;;; Notifications
|
|
|
|
(defun meshmonitor-chat--notify (sender text target-type target)
|
|
"Show desktop notification for a message from SENDER.
|
|
TEXT is the message content, TARGET-TYPE and TARGET identify the chat."
|
|
(when meshmonitor-chat-notify
|
|
(let ((title (pcase target-type
|
|
('channel (format "#%s"
|
|
(meshmonitor-chat--channel-name
|
|
target)))
|
|
('dm sender)
|
|
(_ "MeshMonitor")))
|
|
(body (truncate-string-to-width
|
|
(format "%s: %s" sender text) 100 nil nil "...")))
|
|
(notifications-notify
|
|
:title title
|
|
:body body
|
|
:app-name "MeshMonitor"
|
|
:category "im.received"
|
|
:urgency 'normal))))
|
|
|
|
;;;; Cleanup
|
|
|
|
;;;###autoload
|
|
(defun meshmonitor-chat-disconnect ()
|
|
"Disconnect from MeshMonitor and clean up state."
|
|
(interactive)
|
|
(meshmonitor-chat--stop-polling)
|
|
(setq meshmonitor-chat--connected nil
|
|
meshmonitor-chat--auth-token nil
|
|
meshmonitor-chat--csrf-token nil
|
|
meshmonitor-chat--last-poll-time nil
|
|
meshmonitor-chat--base-url nil
|
|
meshmonitor-chat--my-node-id nil
|
|
meshmonitor-chat--my-node-num nil
|
|
meshmonitor-chat--channels nil
|
|
meshmonitor-chat--chat-buffers nil)
|
|
(clrhash meshmonitor-chat--nodes)
|
|
(clrhash meshmonitor-chat--read-timestamps)
|
|
(message "MeshMonitor: disconnected"))
|
|
|
|
;;;; Status API
|
|
|
|
(defun meshmonitor-chat--fetch-status-sync ()
|
|
"Fetch server status synchronously.
|
|
Try legacy endpoint first (more data), fall back to v1."
|
|
(let ((result (meshmonitor-chat--request "GET" "/api/status")))
|
|
(if (and result (< (car result) 400))
|
|
result
|
|
(meshmonitor-chat--request "GET" "/api/v1/status"))))
|
|
|
|
(defun meshmonitor-chat--fetch-status (callback)
|
|
"Fetch server status asynchronously.
|
|
Try legacy endpoint first (more data), fall back to v1.
|
|
Call CALLBACK with (STATUS-CODE . BODY)."
|
|
(meshmonitor-chat--request
|
|
"GET" "/api/status" nil
|
|
(lambda (result)
|
|
(if (and result (< (car result) 400))
|
|
(funcall callback result)
|
|
(meshmonitor-chat--request
|
|
"GET" "/api/v1/status" nil callback)))))
|
|
|
|
(defun meshmonitor-chat--json-true-p (value)
|
|
"Return non-nil if JSON VALUE is true.
|
|
Handles `json-read' representation where false is `:json-false'."
|
|
(and value (not (eq value :json-false))))
|
|
|
|
(defun meshmonitor-chat--normalize-status (body)
|
|
"Normalize status BODY from v1 or legacy API into a common alist.
|
|
Returns alist with keys: connected, node-name, node-id,
|
|
node-num, version, uptime, nodes, messages, channels."
|
|
(let* ((v1-data (alist-get 'data body))
|
|
(conn (alist-get 'connection body))
|
|
(local-node (and conn (alist-get 'localNode conn)))
|
|
(stats (alist-get 'statistics body)))
|
|
;; v1: { success, data: { connected, localNodeId, longName, ... } }
|
|
;; Legacy: { version, uptime, connection: { connected, localNode },
|
|
;; statistics: { nodes, messages, channels } }
|
|
`((connected . ,(meshmonitor-chat--json-true-p
|
|
(if v1-data
|
|
(alist-get 'connected v1-data)
|
|
(and conn (alist-get 'connected conn)))))
|
|
(node-name . ,(or (and v1-data (alist-get 'longName v1-data))
|
|
(and local-node
|
|
(alist-get 'longName local-node))))
|
|
(node-id . ,(or (and v1-data (alist-get 'localNodeId v1-data))
|
|
(and local-node
|
|
(alist-get 'nodeId local-node))))
|
|
(node-num . ,(or (and v1-data
|
|
(alist-get 'localNodeNum v1-data))
|
|
(and local-node
|
|
(alist-get 'nodeNum local-node))))
|
|
(version . ,(alist-get 'version body))
|
|
(uptime . ,(alist-get 'uptime body))
|
|
(nodes . ,(and stats (alist-get 'nodes stats)))
|
|
(messages . ,(and stats (alist-get 'messages stats)))
|
|
(channels . ,(and stats (alist-get 'channels stats))))))
|
|
|
|
;;;; Welcome buffer
|
|
|
|
(defface meshmonitor-chat-welcome-title-face
|
|
'((t :weight bold :height 1.3))
|
|
"Face for the welcome buffer title.")
|
|
|
|
(defface meshmonitor-chat-welcome-heading-face
|
|
'((t :weight bold))
|
|
"Face for section headings in the welcome buffer.")
|
|
|
|
(defface meshmonitor-chat-welcome-key-face
|
|
'((t :foreground "cyan" :weight bold))
|
|
"Face for keyboard shortcuts in the welcome buffer.")
|
|
|
|
(defvar meshmonitor-chat-welcome-mode-map
|
|
(let ((map (make-sparse-keymap)))
|
|
(define-key map (kbd "c") #'meshmonitor-chat-channels)
|
|
(define-key map (kbd "n") #'meshmonitor-chat-nodes)
|
|
(define-key map (kbd "d") #'meshmonitor-chat-direct-messages)
|
|
(define-key map (kbd "u") #'meshmonitor-chat-unread)
|
|
(define-key map (kbd "g") #'meshmonitor-chat-refresh)
|
|
(define-key map (kbd "q") #'quit-window)
|
|
map)
|
|
"Keymap for `meshmonitor-chat-welcome-mode'.")
|
|
|
|
(define-derived-mode meshmonitor-chat-welcome-mode special-mode
|
|
"MeshMonitor"
|
|
"Major mode for the MeshMonitor welcome screen."
|
|
:group 'meshmonitor-chat
|
|
(buffer-disable-undo)
|
|
(setq truncate-lines t))
|
|
|
|
(defun meshmonitor-chat--format-uptime (seconds)
|
|
"Format SECONDS as a human-readable uptime string."
|
|
(let* ((days (floor (/ seconds 86400)))
|
|
(hours (floor (/ (mod seconds 86400) 3600)))
|
|
(mins (floor (/ (mod seconds 3600) 60))))
|
|
(cond
|
|
((> days 0) (format "%dd %dh" days hours))
|
|
((> hours 0) (format "%dh %dm" hours mins))
|
|
(t (format "%dm" mins)))))
|
|
|
|
(defun meshmonitor-chat--insert-welcome-shortcut (key label)
|
|
"Insert a formatted shortcut KEY with LABEL."
|
|
(insert " ")
|
|
(insert (propertize (format "[%s]" key)
|
|
'face 'meshmonitor-chat-welcome-key-face))
|
|
(insert (format " %-17s" label)))
|
|
|
|
(defun meshmonitor-chat--render-welcome (status-data)
|
|
"Render the welcome buffer with STATUS-DATA."
|
|
(let ((buf (get-buffer-create "*MeshMonitor*"))
|
|
(info (meshmonitor-chat--normalize-status
|
|
(cdr status-data))))
|
|
(with-current-buffer buf
|
|
(let ((inhibit-read-only t))
|
|
(erase-buffer)
|
|
(let* ((connected (alist-get 'connected info))
|
|
(version (or (alist-get 'version info) "?"))
|
|
(uptime (alist-get 'uptime info))
|
|
(node-name (alist-get 'node-name info))
|
|
(node-id (alist-get 'node-id info))
|
|
(sep (propertize (format " %s\n"
|
|
(make-string 38 ?─))
|
|
'face 'meshmonitor-chat-timestamp-face)))
|
|
;; Title.
|
|
(insert "\n")
|
|
(insert (propertize " MeshMonitor Chat\n"
|
|
'face 'meshmonitor-chat-welcome-title-face))
|
|
(insert (propertize (format " %s\n" (make-string 38 ?═))
|
|
'face 'meshmonitor-chat-timestamp-face))
|
|
(insert "\n")
|
|
;; Connection.
|
|
(insert (propertize " Connection\n"
|
|
'face 'meshmonitor-chat-welcome-heading-face))
|
|
(insert (format " Server: %s:%d\n"
|
|
meshmonitor-chat-host
|
|
meshmonitor-chat-port))
|
|
(insert (format " Version: %s\n" version))
|
|
(insert (format " Status: %s\n"
|
|
(propertize
|
|
(if connected "Connected" "Disconnected")
|
|
'face (if connected
|
|
'meshmonitor-chat-delivery-confirmed-face
|
|
'meshmonitor-chat-delivery-failed-face))))
|
|
(when node-name
|
|
(insert (format " Node: %s (%s)\n"
|
|
node-name node-id)))
|
|
(when uptime
|
|
(insert (format " Uptime: %s\n"
|
|
(meshmonitor-chat--format-uptime uptime))))
|
|
(insert "\n")
|
|
;; Statistics.
|
|
(let ((nodes (alist-get 'nodes info))
|
|
(messages (alist-get 'messages info))
|
|
(channels (alist-get 'channels info)))
|
|
(when (or nodes messages channels)
|
|
(insert (propertize " Statistics\n"
|
|
'face 'meshmonitor-chat-welcome-heading-face))
|
|
(when nodes
|
|
(insert (format " Nodes: %s\n" nodes)))
|
|
(when messages
|
|
(insert (format " Messages: %s\n" messages)))
|
|
(when channels
|
|
(insert (format " Channels: %s\n" channels)))
|
|
(insert "\n")))
|
|
;; Separator.
|
|
(insert sep)
|
|
(insert "\n")
|
|
;; Navigation.
|
|
(meshmonitor-chat--insert-welcome-shortcut "c" "Channels")
|
|
(meshmonitor-chat--insert-welcome-shortcut "n" "Nodes")
|
|
(insert "\n")
|
|
(meshmonitor-chat--insert-welcome-shortcut "d" "Direct Messages")
|
|
(meshmonitor-chat--insert-welcome-shortcut "u" "Unread")
|
|
(insert "\n")
|
|
(meshmonitor-chat--insert-welcome-shortcut "g" "Refresh")
|
|
(meshmonitor-chat--insert-welcome-shortcut "q" "Quit")
|
|
(insert "\n\n")
|
|
(insert sep)))
|
|
(meshmonitor-chat-welcome-mode)
|
|
(goto-char (point-min)))))
|
|
|
|
(defun meshmonitor-chat-refresh ()
|
|
"Refresh the MeshMonitor welcome buffer."
|
|
(interactive)
|
|
(meshmonitor-chat--fetch-status
|
|
(lambda (result)
|
|
(when result
|
|
(meshmonitor-chat--render-welcome result)))))
|
|
|
|
;;;; Entry points
|
|
|
|
(defun meshmonitor-chat--show-list-buffer (name mode fetch-fn
|
|
populate-fn)
|
|
"Show a list buffer named NAME with MODE.
|
|
FETCH-FN fetches data and calls its callback with results.
|
|
POPULATE-FN receives the data and populates the buffer."
|
|
(meshmonitor-chat--ensure-connected)
|
|
(let ((buf (get-buffer-create name)))
|
|
(with-current-buffer buf
|
|
(unless (eq major-mode mode)
|
|
(funcall mode)))
|
|
(switch-to-buffer buf)
|
|
(funcall fetch-fn
|
|
(lambda (data)
|
|
(when (buffer-live-p buf)
|
|
(with-current-buffer buf
|
|
(funcall populate-fn data)))))))
|
|
|
|
;;;###autoload
|
|
(defun meshmonitor-chat-channels ()
|
|
"Show the MeshMonitor channel list."
|
|
(interactive)
|
|
(meshmonitor-chat--show-list-buffer
|
|
"*MeshMonitor: Channels*"
|
|
'meshmonitor-chat-channel-list-mode
|
|
(lambda (cb) (meshmonitor-chat--fetch-channels
|
|
(lambda (_) (funcall cb nil))))
|
|
(lambda (_) (meshmonitor-chat--populate-channel-list))))
|
|
|
|
;;;###autoload
|
|
(defun meshmonitor-chat-direct-messages ()
|
|
"Show the MeshMonitor DM conversation list."
|
|
(interactive)
|
|
(meshmonitor-chat--show-list-buffer
|
|
"*MeshMonitor: Direct Messages*"
|
|
'meshmonitor-chat-dm-list-mode
|
|
#'meshmonitor-chat--fetch-dm-conversations
|
|
#'meshmonitor-chat--populate-dm-list))
|
|
|
|
;;;###autoload
|
|
(defun meshmonitor-chat-nodes ()
|
|
"Show all MeshMonitor nodes sorted by hop count."
|
|
(interactive)
|
|
(meshmonitor-chat--show-list-buffer
|
|
"*MeshMonitor: Nodes*"
|
|
'meshmonitor-chat-node-list-mode
|
|
(lambda (cb) (meshmonitor-chat--fetch-nodes
|
|
(lambda () (funcall cb nil))))
|
|
(lambda (_) (meshmonitor-chat--populate-node-list))))
|
|
|
|
;;;###autoload
|
|
(defun meshmonitor-chat-unread ()
|
|
"Show nodes with unread direct messages."
|
|
(interactive)
|
|
(meshmonitor-chat--show-list-buffer
|
|
"*MeshMonitor: Unread*"
|
|
'meshmonitor-chat-unread-list-mode
|
|
#'meshmonitor-chat--fetch-unread
|
|
#'meshmonitor-chat--populate-unread-list))
|
|
|
|
;;;###autoload
|
|
(defun meshmonitor-chat ()
|
|
"Open the MeshMonitor welcome screen."
|
|
(interactive)
|
|
(meshmonitor-chat--ensure-connected)
|
|
(meshmonitor-chat--fetch-status
|
|
(lambda (result)
|
|
(when result
|
|
(meshmonitor-chat--render-welcome result)
|
|
(switch-to-buffer "*MeshMonitor*")))))
|
|
|
|
(provide 'meshmonitor-chat)
|
|
;;; meshmonitor-chat.el ends here
|