mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-08 18:02:46 +02:00
implement support for replies and reactions app (#411)
* implement support for replies and reactions app * Allow numeric reaction port packets * allow reaction packets through mai channel filter
This commit is contained in:
@@ -34,6 +34,7 @@ _RECEIVE_TOPICS = (
|
||||
"meshtastic.receive.NODEINFO_APP",
|
||||
"meshtastic.receive.NEIGHBORINFO_APP",
|
||||
"meshtastic.receive.TEXT_MESSAGE_APP",
|
||||
"meshtastic.receive.REACTION_APP",
|
||||
"meshtastic.receive.TELEMETRY_APP",
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
|
||||
@@ -1051,15 +1054,83 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
store_neighborinfo_packet(packet, decoded)
|
||||
return
|
||||
|
||||
text = _first(decoded, "payload.text", "text", default=None)
|
||||
text = _first(decoded, "payload.text", "text", "data.text", default=None)
|
||||
encrypted = _first(decoded, "payload.encrypted", "encrypted", default=None)
|
||||
if encrypted is None:
|
||||
encrypted = _first(packet, "encrypted", default=None)
|
||||
if not text and not encrypted:
|
||||
reply_id_raw = _first(
|
||||
decoded,
|
||||
"payload.replyId",
|
||||
"payload.reply_id",
|
||||
"data.replyId",
|
||||
"data.reply_id",
|
||||
"replyId",
|
||||
"reply_id",
|
||||
default=None,
|
||||
)
|
||||
reply_id = _coerce_int(reply_id_raw)
|
||||
emoji_raw = _first(
|
||||
decoded,
|
||||
"payload.emoji",
|
||||
"data.emoji",
|
||||
"emoji",
|
||||
default=None,
|
||||
)
|
||||
emoji = None
|
||||
if emoji_raw is not None:
|
||||
try:
|
||||
emoji_text = str(emoji_raw)
|
||||
except Exception:
|
||||
emoji_text = None
|
||||
else:
|
||||
emoji_text = emoji_text.strip()
|
||||
if emoji_text:
|
||||
emoji = emoji_text
|
||||
|
||||
encrypted_flag = _is_encrypted_flag(encrypted)
|
||||
if not any([text, encrypted_flag, emoji is not None, reply_id is not None]):
|
||||
return
|
||||
|
||||
if portnum and portnum not in {"1", "TEXT_MESSAGE_APP"}:
|
||||
return
|
||||
allowed_port_values = {"1", "TEXT_MESSAGE_APP", "REACTION_APP"}
|
||||
allowed_port_ints = {1}
|
||||
|
||||
reaction_port_candidates: set[int] = set()
|
||||
for module_name in (
|
||||
"meshtastic.portnums_pb2",
|
||||
"meshtastic.protobuf.portnums_pb2",
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
with contextlib.suppress(ModuleNotFoundError):
|
||||
module = importlib.import_module(module_name)
|
||||
if module is None:
|
||||
continue
|
||||
portnum_enum = getattr(module, "PortNum", None)
|
||||
value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None
|
||||
if callable(value_lookup):
|
||||
with contextlib.suppress(Exception):
|
||||
candidate = _coerce_int(value_lookup("REACTION_APP"))
|
||||
if candidate is not None:
|
||||
reaction_port_candidates.add(candidate)
|
||||
constant_value = getattr(module, "REACTION_APP", None)
|
||||
candidate = _coerce_int(constant_value)
|
||||
if candidate is not None:
|
||||
reaction_port_candidates.add(candidate)
|
||||
|
||||
for candidate in reaction_port_candidates:
|
||||
allowed_port_ints.add(candidate)
|
||||
allowed_port_values.add(str(candidate))
|
||||
|
||||
is_reaction_packet = portnum == "REACTION_APP" or (
|
||||
reply_id is not None and emoji is not None
|
||||
)
|
||||
if is_reaction_packet and portnum_int is not None:
|
||||
allowed_port_ints.add(portnum_int)
|
||||
allowed_port_values.add(str(portnum_int))
|
||||
|
||||
if portnum and portnum not in allowed_port_values:
|
||||
if portnum_int not in allowed_port_ints:
|
||||
return
|
||||
|
||||
channel = _first(decoded, "channel", default=None)
|
||||
if channel is None:
|
||||
@@ -1096,7 +1167,8 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
to_id_normalized = str(to_id).strip() if to_id is not None else ""
|
||||
|
||||
if (
|
||||
channel == 0
|
||||
not is_reaction_packet
|
||||
and channel == 0
|
||||
and not encrypted_flag
|
||||
and to_id_normalized
|
||||
and to_id_normalized.lower() != "^all"
|
||||
@@ -1124,6 +1196,8 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
"snr": float(snr) if snr is not None else None,
|
||||
"rssi": int(rssi) if rssi is not None else None,
|
||||
"hop_limit": int(hop) if hop is not None else None,
|
||||
"reply_id": reply_id,
|
||||
"emoji": emoji,
|
||||
}
|
||||
|
||||
channel_name_value = None
|
||||
|
||||
+4
-1
@@ -27,7 +27,9 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
hop_limit INTEGER,
|
||||
lora_freq INTEGER,
|
||||
modem_preset TEXT,
|
||||
channel_name TEXT
|
||||
channel_name TEXT,
|
||||
reply_id INTEGER,
|
||||
emoji TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_rx_time ON messages(rx_time);
|
||||
@@ -35,3 +37,4 @@ CREATE INDEX IF NOT EXISTS idx_messages_from_id ON messages(from_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(to_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_portnum ON messages(portnum);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_reply_id ON messages(reply_id);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Copyright (C) 2025 l5yth
|
||||
--
|
||||
-- Licensed under the Apache License, Version 2.0 (the "License");
|
||||
-- you may not use this file except in compliance with the License.
|
||||
-- You may obtain a copy of the License at
|
||||
--
|
||||
-- http://www.apache.org/licenses/LICENSE-2.0
|
||||
--
|
||||
-- Unless required by applicable law or agreed to in writing, software
|
||||
-- distributed under the License is distributed on an "AS IS" BASIS,
|
||||
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
-- See the License for the specific language governing permissions and
|
||||
-- limitations under the License.
|
||||
--
|
||||
-- Extend the messages table to capture reply relationships and emoji reactions.
|
||||
BEGIN;
|
||||
ALTER TABLE messages ADD COLUMN reply_id INTEGER;
|
||||
ALTER TABLE messages ADD COLUMN emoji TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_reply_id ON messages(reply_id);
|
||||
COMMIT;
|
||||
@@ -602,11 +602,53 @@ def test_store_packet_dict_posts_text_message(mesh_module, monkeypatch):
|
||||
assert payload["hop_limit"] == 3
|
||||
assert payload["snr"] == pytest.approx(1.25)
|
||||
assert payload["rssi"] == -70
|
||||
assert payload["reply_id"] is None
|
||||
assert payload["emoji"] is None
|
||||
assert payload["lora_freq"] == 868
|
||||
assert payload["modem_preset"] == "MediumFast"
|
||||
assert priority == mesh._MESSAGE_POST_PRIORITY
|
||||
|
||||
|
||||
def test_store_packet_dict_posts_reaction_message(mesh_module, monkeypatch):
|
||||
mesh = mesh_module
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
mesh,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
||||
)
|
||||
|
||||
packet = {
|
||||
"id": 999,
|
||||
"rxTime": 1_700_100_000,
|
||||
"fromId": "!reply",
|
||||
"toId": "!root",
|
||||
"decoded": {
|
||||
"portnum": "REACTION_APP",
|
||||
"data": {
|
||||
"reply_id": "123",
|
||||
"emoji": " 👍 ",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected POST to be triggered for reaction message"
|
||||
path, payload, priority = captured[0]
|
||||
assert path == "/api/messages"
|
||||
assert payload["id"] == 999
|
||||
assert payload["from_id"] == "!reply"
|
||||
assert payload["to_id"] == "!root"
|
||||
assert payload["portnum"] == "REACTION_APP"
|
||||
assert payload["text"] is None
|
||||
assert payload["reply_id"] == 123
|
||||
assert payload["emoji"] == "👍"
|
||||
assert payload["rx_time"] == 1_700_100_000
|
||||
assert payload["rx_iso"] == mesh._iso(1_700_100_000)
|
||||
assert priority == mesh._MESSAGE_POST_PRIORITY
|
||||
|
||||
|
||||
def test_store_packet_dict_posts_position(mesh_module, monkeypatch):
|
||||
mesh = mesh_module
|
||||
captured = []
|
||||
@@ -1564,6 +1606,8 @@ def test_store_packet_dict_appends_channel_name(mesh_module, monkeypatch, capsys
|
||||
assert payload["channel"] == 5
|
||||
assert payload["text"] == "hi"
|
||||
assert payload["encrypted"] is None
|
||||
assert payload["reply_id"] is None
|
||||
assert payload["emoji"] is None
|
||||
assert priority == mesh._MESSAGE_POST_PRIORITY
|
||||
|
||||
log_output = capsys.readouterr().out
|
||||
@@ -1601,6 +1645,8 @@ def test_store_packet_dict_includes_encrypted_payload(mesh_module, monkeypatch):
|
||||
assert payload["text"] is None
|
||||
assert payload["from_id"] == 2988082812
|
||||
assert payload["to_id"] == "!receiver"
|
||||
assert payload["reply_id"] is None
|
||||
assert payload["emoji"] is None
|
||||
assert "channel_name" not in payload
|
||||
assert payload["lora_freq"] == 868
|
||||
assert payload["modem_preset"] == "MediumFast"
|
||||
|
||||
@@ -1262,6 +1262,8 @@ module PotatoMesh
|
||||
lora_freq = coerce_integer(message["lora_freq"] || message["loraFrequency"])
|
||||
modem_preset = string_or_nil(message["modem_preset"] || message["modemPreset"])
|
||||
channel_name = string_or_nil(message["channel_name"] || message["channelName"])
|
||||
reply_id = coerce_integer(message["reply_id"] || message["replyId"])
|
||||
emoji = string_or_nil(message["emoji"])
|
||||
|
||||
row = [
|
||||
msg_id,
|
||||
@@ -1279,11 +1281,13 @@ module PotatoMesh
|
||||
lora_freq,
|
||||
modem_preset,
|
||||
channel_name,
|
||||
reply_id,
|
||||
emoji,
|
||||
]
|
||||
|
||||
with_busy_retry do
|
||||
existing = db.get_first_row(
|
||||
"SELECT from_id, to_id, encrypted, lora_freq, modem_preset, channel_name FROM messages WHERE id = ?",
|
||||
"SELECT from_id, to_id, encrypted, lora_freq, modem_preset, channel_name, reply_id, emoji FROM messages WHERE id = ?",
|
||||
[msg_id],
|
||||
)
|
||||
if existing
|
||||
@@ -1334,6 +1338,19 @@ module PotatoMesh
|
||||
updates["channel_name"] = channel_name if should_update
|
||||
end
|
||||
|
||||
unless reply_id.nil?
|
||||
existing_reply = existing.is_a?(Hash) ? existing["reply_id"] : existing[6]
|
||||
updates["reply_id"] = reply_id if existing_reply != reply_id
|
||||
end
|
||||
|
||||
if emoji
|
||||
existing_emoji = existing.is_a?(Hash) ? existing["emoji"] : existing[7]
|
||||
existing_emoji_str = existing_emoji&.to_s
|
||||
should_update = existing_emoji_str.nil? || existing_emoji_str.strip.empty?
|
||||
should_update ||= existing_emoji != emoji
|
||||
updates["emoji"] = emoji if should_update
|
||||
end
|
||||
|
||||
unless updates.empty?
|
||||
assignments = updates.keys.map { |column| "#{column} = ?" }.join(", ")
|
||||
db.execute("UPDATE messages SET #{assignments} WHERE id = ?", updates.values + [msg_id])
|
||||
@@ -1343,8 +1360,8 @@ module PotatoMesh
|
||||
|
||||
begin
|
||||
db.execute <<~SQL, row
|
||||
INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit,lora_freq,modem_preset,channel_name)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit,lora_freq,modem_preset,channel_name,reply_id,emoji)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
SQL
|
||||
rescue SQLite3::ConstraintException
|
||||
fallback_updates = {}
|
||||
@@ -1354,6 +1371,8 @@ module PotatoMesh
|
||||
fallback_updates["lora_freq"] = lora_freq unless lora_freq.nil?
|
||||
fallback_updates["modem_preset"] = modem_preset if modem_preset
|
||||
fallback_updates["channel_name"] = channel_name if channel_name
|
||||
fallback_updates["reply_id"] = reply_id unless reply_id.nil?
|
||||
fallback_updates["emoji"] = emoji if emoji
|
||||
unless fallback_updates.empty?
|
||||
assignments = fallback_updates.keys.map { |column| "#{column} = ?" }.join(", ")
|
||||
db.execute("UPDATE messages SET #{assignments} WHERE id = ?", fallback_updates.values + [msg_id])
|
||||
|
||||
@@ -138,6 +138,24 @@ module PotatoMesh
|
||||
db.execute("ALTER TABLE messages ADD COLUMN channel_name TEXT")
|
||||
end
|
||||
|
||||
unless message_columns.include?("reply_id")
|
||||
db.execute("ALTER TABLE messages ADD COLUMN reply_id INTEGER")
|
||||
message_columns << "reply_id"
|
||||
end
|
||||
|
||||
unless message_columns.include?("emoji")
|
||||
db.execute("ALTER TABLE messages ADD COLUMN emoji TEXT")
|
||||
message_columns << "emoji"
|
||||
end
|
||||
|
||||
reply_index_exists =
|
||||
db.get_first_value(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_messages_reply_id'",
|
||||
).to_i > 0
|
||||
unless reply_index_exists
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_reply_id ON messages(reply_id)")
|
||||
end
|
||||
|
||||
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='instances'").flatten
|
||||
if tables.empty?
|
||||
sql_file = File.expand_path("../../../../data/instances.sql", __dir__)
|
||||
|
||||
@@ -216,7 +216,9 @@ module PotatoMesh
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
where_clauses = ["(COALESCE(TRIM(m.text), '') != '' OR COALESCE(TRIM(m.encrypted), '') != '')"]
|
||||
where_clauses = [
|
||||
"(COALESCE(TRIM(m.text), '') != '' OR COALESCE(TRIM(m.encrypted), '') != '' OR m.reply_id IS NOT NULL OR COALESCE(TRIM(m.emoji), '') != '')",
|
||||
]
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
where_clauses << "m.rx_time >= ?"
|
||||
@@ -232,7 +234,8 @@ module PotatoMesh
|
||||
sql = <<~SQL
|
||||
SELECT m.id, m.rx_time, m.rx_iso, m.from_id, m.to_id, m.channel,
|
||||
m.portnum, m.text, m.encrypted, m.rssi, m.hop_limit,
|
||||
m.lora_freq, m.modem_preset, m.channel_name, m.snr
|
||||
m.lora_freq, m.modem_preset, m.channel_name, m.snr,
|
||||
m.reply_id, m.emoji
|
||||
FROM messages m
|
||||
SQL
|
||||
sql += " WHERE #{where_clauses.join(" AND ")}\n"
|
||||
@@ -244,6 +247,8 @@ module PotatoMesh
|
||||
rows = db.execute(sql, params)
|
||||
rows.each do |r|
|
||||
r.delete_if { |key, _| key.is_a?(Integer) }
|
||||
r["reply_id"] = coerce_integer(r["reply_id"]) if r.key?("reply_id")
|
||||
r["emoji"] = string_or_nil(r["emoji"]) if r.key?("emoji")
|
||||
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.strip.empty?)
|
||||
raw = db.execute("SELECT * FROM messages WHERE id = ?", [r["id"]]).first
|
||||
debug_log(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2025 l5yth
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildMessageBody,
|
||||
buildMessageIndex,
|
||||
normaliseMessageId,
|
||||
resolveReplyPrefix
|
||||
} from '../message-replies.js';
|
||||
|
||||
test('normaliseMessageId coerces numeric identifiers', () => {
|
||||
assert.equal(normaliseMessageId(42), '42');
|
||||
assert.equal(normaliseMessageId(' 0042 '), '42');
|
||||
assert.equal(normaliseMessageId('alpha'), 'alpha');
|
||||
assert.equal(normaliseMessageId(null), null);
|
||||
});
|
||||
|
||||
test('buildMessageIndex normalises identifiers and ignores duplicates', () => {
|
||||
const messages = [
|
||||
{ id: '001', text: 'first' },
|
||||
{ packet_id: 1, text: 'second' },
|
||||
{ id: '2', text: 'third' }
|
||||
];
|
||||
const index = buildMessageIndex(messages);
|
||||
assert.equal(index.size, 2);
|
||||
assert.equal(index.get('1'), messages[0]);
|
||||
assert.equal(index.get('2'), messages[2]);
|
||||
});
|
||||
|
||||
test('resolveReplyPrefix renders reply badge and buildMessageBody joins emoji', () => {
|
||||
const parent = {
|
||||
id: 99,
|
||||
node: { short_name: 'BEEF', long_name: 'Parent Node', role: 'CLIENT' },
|
||||
text: 'parent message'
|
||||
};
|
||||
const reaction = { id: 100, reply_id: 99, emoji: '🔥' };
|
||||
const index = buildMessageIndex([parent, reaction]);
|
||||
|
||||
const prefix = resolveReplyPrefix({
|
||||
message: reaction,
|
||||
messagesById: index,
|
||||
nodesById: new Map(),
|
||||
renderShortHtml: (short, role, longName) => `SHORT(${short}|${role}|${longName})`,
|
||||
escapeHtml: value => `ESC(${value})`
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
prefix,
|
||||
'<span class="chat-entry-reply">[ESC(in reply to) SHORT(BEEF|CLIENT|Parent Node)]</span>'
|
||||
);
|
||||
|
||||
const body = buildMessageBody({
|
||||
message: { text: 'Hello', emoji: ' 🔥 ' },
|
||||
escapeHtml: value => `ESC(${value})`,
|
||||
renderEmojiHtml: value => `EMOJI(${value})`
|
||||
});
|
||||
|
||||
assert.equal(body, 'ESC(Hello) EMOJI(🔥)');
|
||||
});
|
||||
@@ -40,6 +40,7 @@ import { initializeInstanceSelector } from './instance-selector.js';
|
||||
import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js';
|
||||
import { renderChatTabs } from './chat-tabs.js';
|
||||
import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js';
|
||||
import { buildMessageBody, buildMessageIndex, resolveReplyPrefix } from './message-replies.js';
|
||||
|
||||
/**
|
||||
* Entry point for the interactive dashboard. Wires up event listeners,
|
||||
@@ -124,7 +125,8 @@ export function initializeApp(config) {
|
||||
/** @type {Array<Object>} */
|
||||
let allNeighbors = [];
|
||||
/** @type {Map<string, Object>} */
|
||||
let nodesById = new Map();
|
||||
let nodesById = new Map();
|
||||
let messagesById = new Map();
|
||||
let nodesByNum = new Map();
|
||||
const messageNodeHydrator = createMessageNodeHydrator({
|
||||
fetchNodeById,
|
||||
@@ -2576,18 +2578,35 @@ export function initializeApp(config) {
|
||||
const tsDate = tsSeconds != null ? new Date(tsSeconds * 1000) : null;
|
||||
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
|
||||
const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
|
||||
let messageCopy = m?.text || '';
|
||||
let copyIsHtml = false;
|
||||
const replyPrefix = resolveReplyPrefix({
|
||||
message: m,
|
||||
messagesById,
|
||||
nodesById,
|
||||
renderShortHtml,
|
||||
escapeHtml
|
||||
});
|
||||
|
||||
let messageBodyHtml = '';
|
||||
if (m && m.encrypted) {
|
||||
const notice = formatEncryptedMessageNotice(m);
|
||||
if (notice && typeof notice === 'object') {
|
||||
messageCopy = notice.content ?? '';
|
||||
copyIsHtml = Boolean(notice.isHtml);
|
||||
const content = notice.content ?? '';
|
||||
messageBodyHtml = notice.isHtml ? content : escapeHtml(content);
|
||||
} else {
|
||||
messageCopy = '';
|
||||
messageBodyHtml = '';
|
||||
}
|
||||
} else {
|
||||
messageBodyHtml = buildMessageBody({
|
||||
message: m || {},
|
||||
escapeHtml,
|
||||
renderEmojiHtml
|
||||
});
|
||||
}
|
||||
const text = copyIsHtml ? messageCopy : escapeHtml(messageCopy);
|
||||
|
||||
const combinedSegments = [];
|
||||
if (replyPrefix) combinedSegments.push(replyPrefix);
|
||||
if (messageBodyHtml) combinedSegments.push(messageBodyHtml);
|
||||
const text = combinedSegments.length > 0 ? combinedSegments.join(' ') : '';
|
||||
const metadata = extractChatMessageMetadata(m);
|
||||
const prefix = formatChatMessagePrefix({
|
||||
timestamp: escapeHtml(ts),
|
||||
@@ -2613,6 +2632,7 @@ export function initializeApp(config) {
|
||||
neighborEntries = []
|
||||
}) {
|
||||
if (!CHAT_ENABLED || !chatEl) return;
|
||||
messagesById = buildMessageIndex(messages);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const { logEntries, channels } = buildChatTabModel({
|
||||
nodes,
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* Copyright (C) 2025 l5yth
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert a value into a trimmed string or return ``null`` for blank inputs.
|
||||
*
|
||||
* @param {*} value Arbitrary input value.
|
||||
* @returns {?string} Trimmed string when present, otherwise ``null``.
|
||||
*/
|
||||
function toTrimmedString(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return String(value);
|
||||
}
|
||||
const str = String(value).trim();
|
||||
return str.length > 0 ? str : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a message identifier to a stable string key.
|
||||
*
|
||||
* @param {*} value Identifier candidate.
|
||||
* @returns {?string} Canonical identifier.
|
||||
*/
|
||||
export function normaliseMessageId(value) {
|
||||
const str = toTrimmedString(value);
|
||||
if (!str) return null;
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
const parsed = Number.parseInt(str, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return String(parsed);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of message identifiers to their payload objects.
|
||||
*
|
||||
* Duplicate identifiers retain the first occurrence encountered, mirroring the
|
||||
* ingestion pipeline that treats message IDs as unique keys.
|
||||
*
|
||||
* @param {?Array<Object>} messages Message collection.
|
||||
* @returns {Map<string, Object>} Identifier lookup.
|
||||
*/
|
||||
export function buildMessageIndex(messages) {
|
||||
const index = new Map();
|
||||
if (!Array.isArray(messages)) {
|
||||
return index;
|
||||
}
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const idValue = message.id ?? message.packet_id ?? message.packetId;
|
||||
const key = normaliseMessageId(idValue);
|
||||
if (!key || index.has(key)) {
|
||||
continue;
|
||||
}
|
||||
index.set(key, message);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of identifier candidates associated with ``message``.
|
||||
*
|
||||
* @param {?Object} message Message payload.
|
||||
* @returns {Array<string>} Identifier candidates.
|
||||
*/
|
||||
function candidateMessageIdentifiers(message) {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const candidates = [
|
||||
message.node_id ?? message.nodeId,
|
||||
message.from_id ?? message.fromId,
|
||||
];
|
||||
const unique = [];
|
||||
for (const candidate of candidates) {
|
||||
const trimmed = toTrimmedString(candidate);
|
||||
if (!trimmed || unique.includes(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
unique.push(trimmed);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the node metadata associated with ``message``.
|
||||
*
|
||||
* @param {?Object} message Message payload.
|
||||
* @param {?Map<string, Object>} nodesById Node lookup table.
|
||||
* @returns {?Object} Node object when available.
|
||||
*/
|
||||
function deriveMessageNode(message, nodesById) {
|
||||
if (message && typeof message === 'object' && message.node && typeof message.node === 'object') {
|
||||
return message.node;
|
||||
}
|
||||
if (!(nodesById instanceof Map)) {
|
||||
return null;
|
||||
}
|
||||
for (const identifier of candidateMessageIdentifiers(message)) {
|
||||
if (nodesById.has(identifier)) {
|
||||
return nodesById.get(identifier);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short name fallback derived from a node identifier.
|
||||
*
|
||||
* @param {string} identifier Node identifier string.
|
||||
* @returns {?string} Short name fallback.
|
||||
*/
|
||||
function fallbackShortFromIdentifier(identifier) {
|
||||
const trimmed = toTrimmedString(identifier);
|
||||
if (!trimmed) return null;
|
||||
const core = trimmed.replace(/^!+/, '');
|
||||
if (core.length >= 4) {
|
||||
return core.slice(-4).toUpperCase();
|
||||
}
|
||||
if (trimmed.length >= 4) {
|
||||
return trimmed.slice(-4).toUpperCase();
|
||||
}
|
||||
return trimmed.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the preferred short name for a reply badge.
|
||||
*
|
||||
* @param {?Object} message Message payload.
|
||||
* @param {?Object} node Node metadata.
|
||||
* @returns {?string} Short name candidate.
|
||||
*/
|
||||
function deriveShortCandidate(message, node) {
|
||||
const candidates = [
|
||||
node?.short_name,
|
||||
node?.shortName,
|
||||
message?.node?.short_name,
|
||||
message?.node?.shortName,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const trimmed = toTrimmedString(candidate);
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
for (const identifier of candidateMessageIdentifiers(message)) {
|
||||
const fallback = fallbackShortFromIdentifier(identifier);
|
||||
if (fallback) return fallback;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the preferred long name for a reply badge tooltip.
|
||||
*
|
||||
* @param {?Object} message Message payload.
|
||||
* @param {?Object} node Node metadata.
|
||||
* @returns {?string} Long name candidate.
|
||||
*/
|
||||
function deriveLongCandidate(message, node) {
|
||||
const candidates = [
|
||||
node?.long_name,
|
||||
node?.longName,
|
||||
message?.node?.long_name,
|
||||
message?.node?.longName,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const trimmed = toTrimmedString(candidate);
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the preferred role for the reply badge.
|
||||
*
|
||||
* @param {?Object} message Message payload.
|
||||
* @param {?Object} node Node metadata.
|
||||
* @returns {?string} Role candidate.
|
||||
*/
|
||||
function deriveRoleCandidate(message, node) {
|
||||
const candidates = [
|
||||
node?.role,
|
||||
message?.node?.role,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const trimmed = toTrimmedString(candidate);
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the reply prefix for a message when the parent is known.
|
||||
*
|
||||
* @param {{
|
||||
* message: Object,
|
||||
* messagesById: Map<string, Object>,
|
||||
* nodesById: Map<string, Object>,
|
||||
* renderShortHtml: Function,
|
||||
* escapeHtml: Function
|
||||
* }} params Rendering dependencies.
|
||||
* @returns {string} HTML snippet or empty string when unavailable.
|
||||
*/
|
||||
export function resolveReplyPrefix({
|
||||
message,
|
||||
messagesById,
|
||||
nodesById,
|
||||
renderShortHtml,
|
||||
escapeHtml
|
||||
}) {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const hasLookup = messagesById instanceof Map;
|
||||
if (!hasLookup) {
|
||||
return '';
|
||||
}
|
||||
const replyKey = normaliseMessageId(message.reply_id ?? message.replyId);
|
||||
if (!replyKey || !messagesById.has(replyKey)) {
|
||||
return '';
|
||||
}
|
||||
if (typeof renderShortHtml !== 'function' || typeof escapeHtml !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parent = messagesById.get(replyKey);
|
||||
const node = deriveMessageNode(parent, nodesById);
|
||||
const shortName = deriveShortCandidate(parent, node);
|
||||
const longName = deriveLongCandidate(parent, node);
|
||||
const role = deriveRoleCandidate(parent, node);
|
||||
const badgeSource = node || (parent && typeof parent === 'object' ? parent.node : null) || null;
|
||||
const shortHtml = renderShortHtml(shortName, role, longName, badgeSource);
|
||||
if (typeof shortHtml !== 'string' || shortHtml.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const label = escapeHtml('in reply to');
|
||||
return `<span class="chat-entry-reply">[${label} ${shortHtml}]</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise an emoji candidate into a trimmed string.
|
||||
*
|
||||
* @param {*} value Emoji candidate.
|
||||
* @returns {?string} Emoji string when valid.
|
||||
*/
|
||||
function normaliseEmojiValue(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return String(value);
|
||||
}
|
||||
const str = String(value).trim();
|
||||
return str.length > 0 ? str : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the rendered message body containing text and optional emoji.
|
||||
*
|
||||
* @param {{
|
||||
* message: Object,
|
||||
* escapeHtml: Function,
|
||||
* renderEmojiHtml: Function
|
||||
* }} params Rendering dependencies.
|
||||
* @returns {string} HTML snippet describing the message body.
|
||||
*/
|
||||
export function buildMessageBody({ message, escapeHtml, renderEmojiHtml }) {
|
||||
if (typeof escapeHtml !== 'function') {
|
||||
throw new TypeError('escapeHtml must be a function');
|
||||
}
|
||||
if (typeof renderEmojiHtml !== 'function') {
|
||||
throw new TypeError('renderEmojiHtml must be a function');
|
||||
}
|
||||
if (!message || typeof message !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const segments = [];
|
||||
if (message.text != null) {
|
||||
const textString = String(message.text);
|
||||
if (textString.length > 0) {
|
||||
segments.push(escapeHtml(textString));
|
||||
}
|
||||
}
|
||||
const emoji = normaliseEmojiValue(message.emoji);
|
||||
if (emoji) {
|
||||
segments.push(renderEmojiHtml(emoji));
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return segments.join(' ');
|
||||
}
|
||||
@@ -533,6 +533,12 @@ th {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.chat-entry-reply {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.chat-entry-msg {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
@@ -1257,6 +1263,10 @@ body.dark .chat-entry-msg {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body.dark .chat-entry-reply {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
body.dark .short-name {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
+47
-1
@@ -2481,7 +2481,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
rows = db.execute(<<~SQL)
|
||||
SELECT id, rx_time, rx_iso, from_id, to_id, channel,
|
||||
portnum, text, snr, rssi, hop_limit,
|
||||
lora_freq, modem_preset, channel_name
|
||||
lora_freq, modem_preset, channel_name,
|
||||
reply_id, emoji
|
||||
FROM messages
|
||||
ORDER BY id
|
||||
SQL
|
||||
@@ -2503,10 +2504,53 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(row["lora_freq"]).to eq(expected["lora_freq"])
|
||||
expect(row["modem_preset"]).to eq(expected["modem_preset"])
|
||||
expect(row["channel_name"]).to eq(expected["channel_name"])
|
||||
expect(row["reply_id"]).to eq(expected["reply_id"])
|
||||
expect(row["emoji"]).to eq(expected["emoji"])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "persists reply metadata and emoji reactions" do
|
||||
parent_payload = {
|
||||
"id" => 42,
|
||||
"rx_time" => reference_time.to_i - 10,
|
||||
"from_id" => "!parent",
|
||||
"channel" => 0,
|
||||
"portnum" => "TEXT_MESSAGE_APP",
|
||||
"text" => "source message",
|
||||
}
|
||||
|
||||
reaction_payload = {
|
||||
"id" => 108,
|
||||
"rx_time" => reference_time.to_i,
|
||||
"from_id" => "!reactor",
|
||||
"channel" => 0,
|
||||
"portnum" => "REACTION_APP",
|
||||
"reply_id" => parent_payload["id"],
|
||||
"emoji" => " 🔥 ",
|
||||
}
|
||||
|
||||
post "/api/messages", parent_payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
post "/api/messages", reaction_payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
row = db.get_first_row("SELECT reply_id, emoji FROM messages WHERE id = ?", [reaction_payload["id"]])
|
||||
expect(row["reply_id"]).to eq(parent_payload["id"])
|
||||
expect(row["emoji"]).to eq("🔥")
|
||||
end
|
||||
|
||||
get "/api/messages"
|
||||
expect(last_response).to be_ok
|
||||
body = JSON.parse(last_response.body)
|
||||
reaction_row = body.find { |entry| entry["id"] == reaction_payload["id"] }
|
||||
expect(reaction_row).not_to be_nil
|
||||
expect(reaction_row["reply_id"]).to eq(parent_payload["id"])
|
||||
expect(reaction_row["emoji"]).to eq("🔥")
|
||||
end
|
||||
|
||||
it "creates hidden nodes for unknown message senders" do
|
||||
payload = {
|
||||
"id" => 9_999,
|
||||
@@ -3570,6 +3614,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(actual_row["lora_freq"]).to eq(expected["lora_freq"])
|
||||
expect(actual_row["modem_preset"]).to eq(expected["modem_preset"])
|
||||
expect(actual_row["channel_name"]).to eq(expected["channel_name"])
|
||||
expect(actual_row["reply_id"]).to eq(expected["reply_id"])
|
||||
expect(actual_row["emoji"]).to eq(expected["emoji"])
|
||||
expect(actual_row["rx_time"]).to eq(expected["rx_time"])
|
||||
expect(actual_row["rx_iso"]).to eq(expected["rx_iso"])
|
||||
expect(actual_row).not_to have_key("node")
|
||||
|
||||
Reference in New Issue
Block a user