web: fix chat (#800)

* chore: add guardrails

* docs: fix meshcore badge

* web: life chat message cap at 1000 in frontend

* web: honor protocol in chat

* web: deprioritize test channels

* fix ci
This commit is contained in:
l5y
2026-06-19 16:37:41 +02:00
committed by GitHub
parent efd2c59fb8
commit bb95b0792d
31 changed files with 1830 additions and 66 deletions
@@ -23,8 +23,10 @@ module PotatoMesh
# @param node_ref [String, Integer, nil] optional node reference to scope results.
# @param include_encrypted [Boolean] when true, include encrypted payloads in the response.
# @param since [Integer] unix timestamp threshold; messages with rx_time older than this are excluded.
# @param before [Integer, nil] inclusive upper-bound rx_time cursor used for
# backward pagination (issue #796); messages newer than this are excluded.
# @return [Array<Hash>] compacted message rows safe for API responses.
def query_messages(limit, node_ref: nil, include_encrypted: false, since: 0, protocol: nil)
def query_messages(limit, node_ref: nil, include_encrypted: false, since: 0, before: nil, protocol: nil)
limit = coerce_query_limit(limit)
now = Time.now.to_i
# Default the chat feed to the same seven-day window the dashboard uses
@@ -42,6 +44,19 @@ module PotatoMesh
where_clauses << "m.rx_time >= ?"
params << since_threshold
# Upper-bound cursor for backward pagination (issue #796). When set,
# +before+ is an *inclusive* ceiling on +rx_time+: the client walks it
# backward (newest -> oldest), passing the oldest +rx_time+ of each page
# as the next cursor and de-duplicating by +id+ client-side, so rows that
# share the boundary second are never skipped. Because the cursor only
# ever *narrows* the result set, the seven-day floor above still bounds
# the window — callers cannot use +before+ to reach further back.
before_cursor = coerce_positive_or_nil(before)
if before_cursor
where_clauses << "m.rx_time <= ?"
params << before_cursor
end
unless include_encrypted
where_clauses << "COALESCE(TRIM(m.encrypted), '') = ''"
end
@@ -168,11 +168,16 @@ module PotatoMesh
include_encrypted = coerce_boolean(params["encrypted"]) || false
since = coerce_integer(params["since"])
since = 0 if since.nil? || since.negative?
# Upper-bound cursor for backward pagination (issue #796). A request
# carrying +before+ is a history page, so it bypasses the shared
# response cache (which only memoises the default newest-page feed).
before = coerce_integer(params["before"])
before = nil if before && before <= 0
protocol = sanitize_protocol(params["protocol"])
enc_key = include_encrypted ? "1" : "0"
if since > 0
json_body = query_messages(limit, include_encrypted: include_encrypted, since: since, protocol: protocol).to_json
if since > 0 || before
json_body = query_messages(limit, include_encrypted: include_encrypted, since: since, before: before, protocol: protocol).to_json
etag Digest::MD5.hexdigest(json_body), kind: :weak
api_cache_control
json_body
@@ -39,8 +39,8 @@ function makeNode(overrides = {}) {
// ---------------------------------------------------------------------------
test('renderChatEntryContent: MeshCore channel leading @[Name] becomes reply prefix', () => {
const alice = makeNode({ node_id: '!11111111', short_name: 'AL', long_name: 'Alice' });
const bob = makeNode({ node_id: '!22222222', short_name: 'BO', long_name: 'Bob' });
const alice = makeNode({ node_id: '!11111111', short_name: 'AL', long_name: 'Alice', protocol: 'meshcore' });
const bob = makeNode({ node_id: '!22222222', short_name: 'BO', long_name: 'Bob', protocol: 'meshcore' });
const nodesById = new Map([
[alice.node_id, alice],
[bob.node_id, bob],
@@ -71,7 +71,7 @@ test('renderChatEntryContent: MeshCore channel leading @[Name] becomes reply pre
});
test('renderChatEntryContent: MeshCore channel leading @[Name] handles name whitespace', () => {
const timo = makeNode({ node_id: '!6aee769f', short_name: 'TI', long_name: '\u{1F4FA} Timo +' });
const timo = makeNode({ node_id: '!6aee769f', short_name: 'TI', long_name: '\u{1F4FA} Timo +', protocol: 'meshcore' });
const nodesById = new Map([[timo.node_id, timo]]);
const message = {
text: 'Bob: @[ Timo +] vielleicht hat jemand einen tip',
@@ -95,8 +95,8 @@ test('renderChatEntryContent: MeshCore channel leading @[Name] handles name whit
});
test('renderChatEntryContent: MeshCore multi-mention body does NOT emit reply prefix', () => {
const alice = makeNode({ node_id: '!11111111', short_name: 'AL', long_name: 'Alice' });
const bob = makeNode({ node_id: '!22222222', short_name: 'BO', long_name: 'Bob' });
const alice = makeNode({ node_id: '!11111111', short_name: 'AL', long_name: 'Alice', protocol: 'meshcore' });
const bob = makeNode({ node_id: '!22222222', short_name: 'BO', long_name: 'Bob', protocol: 'meshcore' });
const nodesById = new Map([[alice.node_id, alice], [bob.node_id, bob]]);
const message = {
text: 'X: @[Alice] and @[Bob] both',
@@ -119,12 +119,60 @@ test('renderChatEntryContent: MeshCore multi-mention body does NOT emit reply pr
assert.ok(html.includes('SHORT(BO|CLIENT|Bob)'));
});
test('renderChatEntryContent: leading mention with unresolved node still surfaces a reply prefix using the raw name (#727)', () => {
test('renderChatEntryContent: MeshCore reply does not quote a same-named Meshtastic node (protocol collision)', () => {
// A Meshtastic and a MeshCore node share the long name "Timo". The
// Meshtastic node is inserted first, so the protocol-blind lookup returns it.
// A MeshCore message quoting @[Timo] must badge the MeshCore node, never the
// Meshtastic one.
const meshtastic = makeNode({ node_id: '!10000001', short_name: 'MTMT', long_name: 'Timo', role: 'ROUTER', protocol: 'meshtastic' });
const meshcore = makeNode({ node_id: '!20000002', short_name: 'MCMC', long_name: 'Timo', role: 'CLIENT', protocol: 'meshcore' });
const nodesById = new Map([
[meshtastic.node_id, meshtastic],
[meshcore.node_id, meshcore],
]);
const message = { text: 'X: @[Timo] thanks!', protocol: 'meshcore', to_id: '^all' };
const { html } = renderChatEntryContent({
message,
nodesById,
messagesById: new Map(),
renderShortHtml,
escapeHtml: esc,
renderEmojiHtml: emoji,
});
assert.ok(html.includes('chat-entry-reply'), 'leading mention becomes a reply prefix');
assert.ok(html.includes('SHORT(MCMC|CLIENT|Timo)'), 'reply target must be the MeshCore node');
assert.ok(!html.includes('MTMT'), 'reply must NOT quote the same-named Meshtastic node');
});
test('renderChatEntryContent: MeshCore mention synthesises a node when only a same-named Meshtastic node exists', () => {
// Only a Meshtastic "Timo" is in the registry. A MeshCore message must NOT
// quote it; instead a synthetic MeshCore-stamped badge carrying the name is
// rendered (issue: don't quote meshtastic nodes in a meshcore message).
const meshtastic = makeNode({ node_id: '!10000001', short_name: 'MTMT', long_name: 'Timo', role: 'ROUTER', protocol: 'meshtastic' });
const nodesById = new Map([[meshtastic.node_id, meshtastic]]);
const message = { text: 'X: hi @[Timo] and @[Timo]', protocol: 'meshcore', to_id: '^all' };
const { html } = renderChatEntryContent({
message,
nodesById,
messagesById: new Map(),
renderShortHtml,
escapeHtml: esc,
renderEmojiHtml: emoji,
});
assert.ok(html.includes('SHORT(Timo|-|Timo)'), 'mention renders a synthetic node badge carrying the name');
assert.ok(!html.includes('MTMT'), 'mention must NOT resolve to the same-named Meshtastic node');
});
test('renderChatEntryContent: leading mention with unresolved node surfaces a reply prefix using a synthetic node badge (#727)', () => {
// Production deployments cap ``/api/nodes`` at 1000 entries, so the global
// registry can be missing nodes that recent messages reference. In that
// case the leading-mention-as-reply detection must still emit a reply
// prefix using the bare mention name, otherwise the body would render as
// ``@[Name] body...`` and look like an unresolved mention link.
// case the leading-mention-as-reply detection still emits a reply prefix, now
// backed by a protocol-stamped synthetic node badge (never a bare
// ``@[Name] body...`` leak, and never a same-named node from another protocol).
const nodesById = new Map();
const message = {
text: 'X: @[DA6ML/p] ja, klingt sehr gut',
@@ -143,15 +191,16 @@ test('renderChatEntryContent: leading mention with unresolved node still surface
assert.ok(html.includes('chat-entry-reply'), 'should include a reply prefix even without a node match');
assert.ok(html.includes('ESC(in reply to)'), 'reply prefix label is escaped');
assert.ok(html.includes('ESC(DA6ML/p)'), 'mention name is shown verbatim (escaped)');
assert.ok(html.includes('SHORT(DA6ML/p|-|DA6ML/p)'), 'mention renders as a synthetic node badge carrying the name');
assert.ok(html.includes('ESC(ja, klingt sehr gut)'), 'remaining text rendered after the prefix');
// The bare ``@[Name]`` form must NOT survive into the body.
assert.ok(!html.includes('@[ESC('), 'unresolved mention should not leak into the body');
});
test('renderChatEntryContent: inline (non-leading) mentions still render as escaped literals when unresolved', () => {
// Mentions that are NOT at the start are left as escaped literals — the
// reply-prefix fallback only applies to leading-mention-as-reply.
test('renderChatEntryContent: inline (non-leading) unresolved mentions render as synthetic node badges', () => {
// Mentions that are NOT at the start no longer fall back to an escaped
// ``@[Name]`` literal; they render a protocol-stamped synthetic node badge so
// the mention is honored without borrowing a node from another protocol.
const nodesById = new Map();
const message = {
text: 'X: hello @[Unknown] there',
@@ -169,7 +218,8 @@ test('renderChatEntryContent: inline (non-leading) mentions still render as esca
});
assert.ok(!html.includes('chat-entry-reply'), 'mid-text mention must not become reply prefix');
assert.ok(html.includes('@[ESC(Unknown)]'), 'unresolved inline mention falls back to escaped literal');
assert.ok(html.includes('SHORT(Unknown|-|Unknown)'), 'unresolved inline mention renders a synthetic node badge');
assert.ok(!html.includes('@[ESC(Unknown)]'), 'bare escaped literal must not survive');
});
test('renderChatEntryContent: MeshCore DM leading mention also becomes reply prefix', () => {
@@ -287,7 +337,7 @@ test('renderChatEntryContent: encrypted message without notice formatter returns
// ---------------------------------------------------------------------------
test('renderChatEntryContent: returns meshcoreSenderNode when prefix resolves against registry', () => {
const sender = makeNode({ node_id: '!11111111', short_name: 'SN', long_name: 'Sender' });
const sender = makeNode({ node_id: '!11111111', short_name: 'SN', long_name: 'Sender', protocol: 'meshcore' });
const nodesById = new Map([[sender.node_id, sender]]);
const message = {
text: 'Sender: hello everyone',
@@ -20,6 +20,7 @@ import assert from 'node:assert/strict';
import {
CHAT_LOG_ENTRY_TYPES,
buildChatTabModel,
isTestChannelLabel,
MAX_CHANNEL_INDEX,
normaliseChannelIndex,
normaliseChannelName,
@@ -27,6 +28,42 @@ import {
} from '../chat-log-tabs.js';
const NOW = 1_000_000;
// ---------------------------------------------------------------------------
// isTestChannelLabel — word-boundary ping/test/bot detection (SPEC F2)
// ---------------------------------------------------------------------------
test('isTestChannelLabel: matches standalone keywords case-insensitively', () => {
for (const label of ['test', 'TEST', 'Ping', 'bot', '#test', '#ping', '#bot']) {
assert.equal(isTestChannelLabel(label), true, `${label} should be a test channel`);
}
});
test('isTestChannelLabel: matches a keyword as one word among others', () => {
for (const label of ['test channel', 'my bot', 'ping pong', 'daily-test', 'bot 2', 'EU ping']) {
assert.equal(isTestChannelLabel(label), true, `${label} should be a test channel`);
}
});
test('isTestChannelLabel: does NOT match keywords embedded in larger words', () => {
// The false positives the word-boundary rule exists to avoid (SPEC F2).
for (const label of ['Camping', 'Robotics', 'RobotWars', 'Contest', 'Botswana', 'Testing', 'testbed', 'MyBot', 'test2', 'pingu']) {
assert.equal(isTestChannelLabel(label), false, `${label} should NOT be a test channel`);
}
});
test('isTestChannelLabel: real default/custom channel names are not test channels', () => {
for (const label of ['Public', 'MediumFast', 'LongFast', '0', '#BerlinMesh', 'MeshTown']) {
assert.equal(isTestChannelLabel(label), false, `${label} should NOT be a test channel`);
}
});
test('isTestChannelLabel: non-string input returns false', () => {
assert.equal(isTestChannelLabel(null), false);
assert.equal(isTestChannelLabel(undefined), false);
assert.equal(isTestChannelLabel(7), false);
assert.equal(isTestChannelLabel(''), false);
});
const WINDOW = 60 * 60; // one hour
function fixtureNodes() {
@@ -92,8 +129,10 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
);
assert.equal(model.channels.length, 6);
// Primary channels (index 0) come first, secondary channels (index > 0) come last.
// Within each tier, ties on messageCount are broken alphabetically by label.
// Default/primary channels (index 0) lead, then custom channels (index > 0);
// these fixtures contain no test channels, so the third (test) tier is
// exercised by the dedicated three-tier ordering tests below. Within each
// tier, ties on messageCount are broken alphabetically by label.
assert.deepEqual(model.channels.map(channel => channel.label), [
'EnvDefault',
'Fallback',
@@ -142,6 +181,65 @@ test('buildChatTabModel skips channel buckets when there are no messages', () =>
assert.equal(model.channels.length, 0);
});
// ---------------------------------------------------------------------------
// Three-tier channel ordering: default -> custom -> test (SPEC F1/F3/F4)
// ---------------------------------------------------------------------------
test('buildChatTabModel sinks test channels below custom channels even with more activity (F1)', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
// Custom channel, low activity (1 message).
{ id: 'c1', rx_time: NOW - 5, channel: 1, channel_name: 'BerlinMesh' },
// Test channel, HIGH activity (3 messages) — must still sort last.
{ id: 't1', rx_time: NOW - 4, channel: 2, channel_name: 'test' },
{ id: 't2', rx_time: NOW - 3, channel: 2, channel_name: 'test' },
{ id: 't3', rx_time: NOW - 2, channel: 2, channel_name: 'test' },
// Default/primary channel, low activity (1 message) — must lead.
{ id: 'p1', rx_time: NOW - 6, channel: 0, channel_name: 'MediumFast' },
],
nowSeconds: NOW,
windowSeconds: WINDOW,
primaryChannelFallbackLabel: '',
});
assert.deepEqual(model.channels.map(channel => channel.label), ['MediumFast', 'BerlinMesh', 'test']);
// Presentation-only (F4): the demoted test channel keeps all its messages.
assert.equal(findChannelByLabel(model, 'test').messageCount, 3);
});
test('buildChatTabModel never demotes an index-0 channel even if its name matches a keyword (F3)', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
{ id: 'cust', rx_time: NOW - 5, channel: 1, channel_name: 'BerlinMesh' },
{ id: 'prim', rx_time: NOW - 4, channel: 0, channel_name: 'test' }, // primary literally named "test"
],
nowSeconds: NOW,
windowSeconds: WINDOW,
primaryChannelFallbackLabel: '',
});
// The index-0 "test" channel still leads; it is NOT sunk to the test tier.
assert.deepEqual(model.channels.map(channel => channel.label), ['test', 'BerlinMesh']);
assert.equal(model.channels[0].index, 0);
});
test('buildChatTabModel orders channels within the test tier by activity then label (F1)', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
{ id: 'a1', rx_time: NOW - 5, channel: 1, channel_name: 'AlphaMesh' }, // custom (tier 1)
{ id: 'pb1', rx_time: NOW - 4, channel: 2, channel_name: 'ping-bot' }, // test, 1 message
{ id: 'tt1', rx_time: NOW - 3, channel: 3, channel_name: 'test' }, // test, 2 messages
{ id: 'tt2', rx_time: NOW - 2, channel: 3, channel_name: 'test' },
],
nowSeconds: NOW,
windowSeconds: WINDOW,
primaryChannelFallbackLabel: '',
});
// Custom first; then test channels, busier ('test', 2) before quieter ('ping-bot', 1).
assert.deepEqual(model.channels.map(channel => channel.label), ['AlphaMesh', 'test', 'ping-bot']);
});
test('buildChatTabModel falls back to numeric label when no metadata provided', () => {
const model = buildChatTabModel({
nodes: [],
@@ -16,7 +16,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { maxRecordTimestamp, mergeById, mergeByCompositeKey, trimToLimit } from '../incremental-helpers.js';
import { maxRecordTimestamp, mergeById, mergeByCompositeKey, trimToLimit, trimToWindow } from '../incremental-helpers.js';
// ---------------------------------------------------------------------------
// maxRecordTimestamp
@@ -210,3 +210,49 @@ test('trimToLimit handles records with missing timestamp fields', () => {
assert.equal(result.length, 2);
assert.equal(result[0].id, 3);
});
// ---------------------------------------------------------------------------
// trimToWindow (issue #796)
// ---------------------------------------------------------------------------
test('trimToWindow drops records older than the floor and keeps the boundary', () => {
const records = [
{ id: 1, rx_time: 90 },
{ id: 2, rx_time: 100 }, // exactly at the floor → kept
{ id: 3, rx_time: 150 },
];
const result = trimToWindow(records, 100);
assert.deepEqual(result.map(r => r.id), [2, 3]);
});
test('trimToWindow retains records with a missing or non-numeric timestamp', () => {
const records = [
{ id: 1 },
{ id: 2, rx_time: 'nope' },
{ id: 3, rx_time: 50 },
{ id: 4, rx_time: 500 },
];
const result = trimToWindow(records, 100);
assert.deepEqual(result.map(r => r.id), [1, 2, 4]);
});
test('trimToWindow uses a custom timestamp field', () => {
const records = [
{ id: 1, last_heard: 10 },
{ id: 2, last_heard: 200 },
];
const result = trimToWindow(records, 100, 'last_heard');
assert.deepEqual(result.map(r => r.id), [2]);
});
test('trimToWindow returns the input unchanged for an unusable floor', () => {
const records = [{ id: 1, rx_time: 10 }];
assert.equal(trimToWindow(records, 0), records);
assert.equal(trimToWindow(records, Number.NaN), records);
assert.equal(trimToWindow(records, -5), records);
});
test('trimToWindow returns input for non-array values', () => {
assert.equal(trimToWindow(null, 100), null);
assert.equal(trimToWindow(undefined, 100), undefined);
});
@@ -238,3 +238,59 @@ test('since parameter uses a 1-second overlap to avoid missing rows', async () =
);
});
});
test('first load pages the chat window backward with a before cursor (issue #796)', async () => {
const now = Math.floor(Date.now() / 1000);
// Page 1 is a *full* page (1000 rows), so the pager must request another page.
const page1 = Array.from({ length: 1000 }, (_, i) => ({
id: 5000 - i, rx_time: now - 60 - i, from_id: '!aabb', text: `m${i}`,
}));
const oldestPage1 = page1[page1.length - 1].rx_time; // inclusive cursor for page 2
// Page 2 re-returns the boundary row (must be de-duplicated) plus older rows,
// then is short — ending the walk.
const page2 = [
{ id: 4001, rx_time: oldestPage1, from_id: '!aabb', text: 'boundary' },
...Array.from({ length: 200 }, (_, i) => ({
id: 4000 - i, rx_time: oldestPage1 - 1 - i, from_id: '!aabb', text: `o${i}`,
})),
];
const env = createDomEnvironment({ includeBody: true });
const originalFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = (url, options = {}) => {
calls.push({ url, options });
let body = [];
if (url.includes('/api/messages') && !url.includes('encrypted=true')) {
body = url.includes('before=') ? page2 : page1;
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
};
try {
initializeApp(BASE_CONFIG);
await new Promise(r => setTimeout(r, 100));
const plaintextMsgCalls = calls.filter(
c => c.url.includes('/api/messages') && !c.url.includes('encrypted=true'),
);
// A full first page must be followed by a backward page; the cursor is the
// oldest rx_time of page 1 (issue #796).
assert.ok(
plaintextMsgCalls.length >= 2,
`expected backward pagination, saw ${plaintextMsgCalls.length} message call(s)`,
);
assert.ok(!plaintextMsgCalls[0].url.includes('before='), 'first page must not carry a cursor');
assert.ok(
plaintextMsgCalls.some(c => c.url.includes(`before=${oldestPage1}`)),
`expected a backward page with before=${oldestPage1}`,
);
// The initial load must not use the incremental `since` cursor.
assert.ok(
plaintextMsgCalls.every(c => !c.url.includes('since=')),
'first load should paginate with before, never since',
);
} finally {
globalThis.fetch = originalFetch;
env.cleanup();
}
});
@@ -302,13 +302,16 @@ test('createMessageChatEntry: meshcore message with @[Name] mention resolved to
});
});
test('createMessageChatEntry: meshcore message with @[Name] mention, node not found — fallback', () => {
test('createMessageChatEntry: meshcore message with @[Name] mention, node not found — synthetic badge', () => {
withApp((t) => {
t.rebuildNodeIndex([]);
const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('EchoBot: Pong! @[Ghost]', { rx_time: 3000 }));
const html = innerHtml(div);
// @[Ghost] mention with no matching node renders as escaped plain text
assert.ok(html.includes('@[Ghost]'), 'unresolved mention should render as escaped @[Name] text');
// @[Ghost] mention with no matching node renders a synthetic protocol-stamped
// node badge carrying the name, never a bare ``@[Name]`` literal.
assert.ok(html.includes('Ghost'), 'unresolved mention should render a synthetic badge carrying the name');
assert.ok(html.includes('Pong!'), 'body text should still render');
assert.ok(!html.includes('@[Ghost]'), 'bare @[Name] literal must not survive');
});
});
@@ -20,6 +20,7 @@ import assert from 'node:assert/strict';
import {
parseMeshcoreSenderPrefix,
findNodeByLongName,
buildSyntheticChatNode,
extractLeadingMentionAsReply,
} from '../meshcore-chat-helpers.js';
@@ -217,6 +218,69 @@ test('findNodeByLongName: whitespace-only input returns null', () => {
assert.equal(findNodeByLongName(' ', map), null);
});
// ---------------------------------------------------------------------------
// findNodeByLongName — protocol-aware resolution (no cross-protocol quoting)
// ---------------------------------------------------------------------------
test('findNodeByLongName: honors protocol and never returns a different-protocol node', () => {
// A Meshtastic and a MeshCore node share the long name "Timo". The
// Meshtastic node is inserted first, so the protocol-blind scan returns it.
const meshtastic = { node_id: '!10000001', long_name: 'Timo', protocol: 'meshtastic' };
const meshcore = { node_id: '!20000002', long_name: 'Timo', protocol: 'meshcore' };
const map = new Map([
['!10000001', meshtastic],
['!20000002', meshcore],
]);
assert.equal(findNodeByLongName('Timo', map, 'meshcore'), meshcore);
assert.equal(findNodeByLongName('Timo', map, 'meshtastic'), meshtastic);
});
test('findNodeByLongName: returns null when only a different-protocol node matches', () => {
const meshtastic = { node_id: '!10000001', long_name: 'Timo', protocol: 'meshtastic' };
const map = new Map([['!10000001', meshtastic]]);
// A MeshCore message must not borrow the Meshtastic node, even as a last resort.
assert.equal(findNodeByLongName('Timo', map, 'meshcore'), null);
});
test('findNodeByLongName: an unstamped node never matches a MeshCore request', () => {
// Absent protocol normalises to the Meshtastic default, so it is excluded
// from MeshCore resolution but eligible for Meshtastic resolution.
const node = { node_id: '!10000001', long_name: 'Timo' };
const map = new Map([['!10000001', node]]);
assert.equal(findNodeByLongName('Timo', map, 'meshcore'), null);
assert.equal(findNodeByLongName('Timo', map, 'meshtastic'), node);
});
test('findNodeByLongName: protocol filter also applies to the emoji-prefix fallback pass', () => {
// Same emoji-stripping fallback name, one node per protocol; the MeshCore
// request must resolve the MeshCore node despite the Meshtastic one matching
// the same stripped name first.
const meshtastic = { node_id: '!10000001', long_name: '\u{1F4FA} Timo +', protocol: 'meshtastic' };
const meshcore = { node_id: '!20000002', long_name: '\u{1F4FA} Timo +', protocol: 'meshcore' };
const map = new Map([
['!10000001', meshtastic],
['!20000002', meshcore],
]);
assert.equal(findNodeByLongName('Timo +', map, 'meshcore'), meshcore);
});
// ---------------------------------------------------------------------------
// buildSyntheticChatNode — protocol-stamped stand-in for unmatched names
// ---------------------------------------------------------------------------
test('buildSyntheticChatNode: carries the name as short/long name and stamps the protocol', () => {
assert.deepEqual(buildSyntheticChatNode('Bob', 'meshcore'), {
short_name: 'Bob',
long_name: 'Bob',
protocol: 'meshcore',
});
});
test('buildSyntheticChatNode: omits the protocol key when none is supplied', () => {
assert.deepEqual(buildSyntheticChatNode('Bob', null), { short_name: 'Bob', long_name: 'Bob' });
assert.deepEqual(buildSyntheticChatNode('Bob'), { short_name: 'Bob', long_name: 'Bob' });
});
// ---------------------------------------------------------------------------
// extractLeadingMentionAsReply — MeshCore leading-mention detection (#727)
// ---------------------------------------------------------------------------
+27 -22
View File
@@ -16,6 +16,7 @@
import { buildMessageBody, resolveReplyPrefix } from './message-replies.js';
import {
buildSyntheticChatNode,
extractLeadingMentionAsReply,
findNodeByLongName,
parseMeshcoreSenderPrefix,
@@ -85,8 +86,10 @@ function formatReplyPrefixHtml(label, badgeHtml, escapeHtml) {
* 2. MeshCore ``"SenderName: body"`` prefix parsing for channel messages.
* 3. MeshCore leading-``@[Name]`` detection, surfacing it as an ``[in reply
* to BADGE]`` prefix when no structured reply is already present.
* 4. Mention rendering for MeshCore messages, mapping ``@[Name]`` to either
* a badge (when the named node is known) or an escaped literal fallback.
* 4. Mention rendering for MeshCore messages, mapping ``@[Name]`` to a badge.
* Name resolution is restricted to nodes of the message's own protocol so
* a MeshCore message never quotes a same-named Meshtastic node; when no
* same-protocol node matches, a synthetic protocol-stamped node is badged.
* 5. ``buildMessageBody()`` invocation, which handles URL linkification,
* emoji rendering, and reaction detection.
* 6. Encrypted-message notices when available from the caller.
@@ -145,7 +148,12 @@ export function renderChatEntryContent({
if (isMeshcoreChannelMsg && typeof message.text === 'string') {
parsedMeshcorePrefix = parseMeshcoreSenderPrefix(message.text);
if (parsedMeshcorePrefix && !message.node) {
meshcoreSenderNode = findNodeByLongName(parsedMeshcorePrefix.senderName, nodesById);
// Resolve the sender only among same-protocol nodes; when none matches,
// synthesise a protocol-stamped stand-in rather than borrowing a
// same-named node from another protocol (issue: honor protocol in chat).
meshcoreSenderNode =
findNodeByLongName(parsedMeshcorePrefix.senderName, nodesById, protocol) ??
buildSyntheticChatNode(parsedMeshcorePrefix.senderName, protocol);
}
}
@@ -189,20 +197,15 @@ export function renderChatEntryContent({
if (!replyPrefix && isMeshcore && effectiveBodyText) {
const leading = extractLeadingMentionAsReply(effectiveBodyText);
if (leading) {
const replyNode = findNodeByLongName(leading.mentionName, nodesById);
let badgeHtml = '';
if (replyNode) {
badgeHtml = renderNodeBadge(renderShortHtml, replyNode);
}
// Graceful degradation: when the registry doesn't contain the
// mention target (common on large deployments where ``/api/nodes``
// caps at 1000 entries by recency), still surface the leading
// mention as a reply prefix using the raw name. Without this
// fallback the body would render as bare ``@[Name] body...`` which
// looks like an unresolved mention link to the user.
if (typeof badgeHtml !== 'string' || badgeHtml.length === 0) {
badgeHtml = `<span class="short-name">${escapeHtml(leading.mentionName)}</span>`;
}
// Resolve the quoted node among same-protocol nodes only. When none
// matches — whether because the registry lacks it (``/api/nodes`` caps by
// recency) or only a different-protocol node shares the name — synthesise
// a protocol-stamped stand-in so the reply badge is always rendered with
// the correct protocol and never quotes a node from another protocol.
const replyNode =
findNodeByLongName(leading.mentionName, nodesById, protocol) ??
buildSyntheticChatNode(leading.mentionName, protocol);
const badgeHtml = renderNodeBadge(renderShortHtml, replyNode);
meshcoreReplyPrefix = formatReplyPrefixHtml('in reply to', badgeHtml, escapeHtml);
effectiveBodyText = leading.remainingText ?? '';
}
@@ -213,11 +216,13 @@ export function renderChatEntryContent({
// ------------------------------------------------------------------
const renderMentionHtml = isMeshcore
? (mentionedName) => {
const mentionNode = findNodeByLongName(mentionedName, nodesById);
if (mentionNode) {
return renderNodeBadge(renderShortHtml, mentionNode);
}
return `@[${escapeHtml(mentionedName)}]`;
// Same-protocol resolution with a protocol-stamped synthetic fallback,
// so an unresolved mention renders a MeshCore badge instead of either a
// bare ``@[Name]`` literal or a same-named Meshtastic node.
const mentionNode =
findNodeByLongName(mentionedName, nodesById, protocol) ??
buildSyntheticChatNode(mentionedName, protocol);
return renderNodeBadge(renderShortHtml, mentionNode);
}
: null;
+53 -7
View File
@@ -22,6 +22,50 @@ import { extractModemMetadata } from './node-modem-metadata.js';
*/
export const MAX_CHANNEL_INDEX = 255;
/**
* Matches a throwaway "test" channel by the presence of the standalone word
* ``ping``, ``test``, or ``bot`` (case-insensitive). The ``\b`` word boundaries
* are deliberate: they keep legitimate channels whose names merely *contain*
* those letters "Camping", "Robotics", "Contest", "Botswana" out of the test
* tier, trading the odd concatenated form ("MyBot", "test2") for zero false
* positives (SPEC F2).
* @type {RegExp}
*/
const TEST_CHANNEL_PATTERN = /\b(?:ping|test|bot)\b/i;
/**
* Decide whether a channel label denotes a deprioritized "test" channel.
*
* Used by {@link buildChatTabModel} to sink ``#test`` / ``#ping`` / ``#bot``
* style channels below the community's real channels (SPEC F1/F2). Matching is
* on the resolved display label the operator sees, case-insensitive and bounded
* to whole words so substrings never trigger a false positive.
*
* @param {string} label Resolved channel display label.
* @returns {boolean} ``true`` when the label contains a standalone test keyword.
*/
export function isTestChannelLabel(label) {
if (typeof label !== 'string') return false;
return TEST_CHANNEL_PATTERN.test(label);
}
/**
* Classify a channel bucket into its display-ordering tier (SPEC F1/F3).
* Lower tiers sort first:
*
* 0 default/primary channel (index 0). Always leads and is **never** demoted
* to the test tier, even if its label matches a keyword (SPEC F3).
* 2 test channel: a non-primary channel whose label names ping/test/bot.
* 1 any other custom (non-primary, non-test) channel.
*
* @param {{ index: number, label: string }} channel Channel bucket.
* @returns {number} Ordering tier (0, 1, or 2).
*/
function channelPriorityTier(channel) {
if (channel.index === 0) return 0;
return isTestChannelLabel(channel.label) ? 2 : 1;
}
/**
* Discrete event types that can appear in the chat activity log.
*
@@ -311,14 +355,16 @@ export function buildChatTabModel({
channel.entries.sort((a, b) => a.ts - b.ts);
channel.messageCount = channel.entries.length;
}
// Sort channels into two tiers:
// 1. Primary channels (channel index 0 — LongFast, MediumFast, Public, etc.)
// ordered by activity desc so the most-active protocol leads within the tier.
// 2. Secondary channels (index > 0) ordered by activity desc, then alpha.
// Within each tier, ties on messageCount are broken alphabetically by label.
// Sort channels into three priority tiers (SPEC F1):
// 0. Default/primary channels (index 0 — LongFast, MediumFast, Public, …),
// never demoted even if the name matches a test keyword (SPEC F3).
// 1. Custom channels (index > 0) that are not test channels.
// 2. Test channels (index > 0 whose label names ping/test/bot) — sunk last.
// Within each tier the prior ordering is preserved unchanged: activity
// (7-day message count) descending, then label alphabetical.
const channels = Array.from(channelBuckets.values()).sort((a, b) => {
const aTier = a.index === 0 ? 0 : 1;
const bTier = b.index === 0 ? 0 : 1;
const aTier = channelPriorityTier(a);
const bTier = channelPriorityTier(b);
if (aTier !== bTier) return aTier - bTier;
return b.messageCount - a.messageCount || a.label.localeCompare(b.label);
});
@@ -106,3 +106,29 @@ export function trimToLimit(records, limit, tsField = 'rx_time') {
const sorted = records.slice().sort((a, b) => (b[tsField] || 0) - (a[tsField] || 0));
return sorted.slice(0, limit);
}
/**
* Drop records older than a timestamp floor, keeping the retained set aligned
* with a rolling window rather than a fixed row count.
*
* The chat feed pages the whole seven-day window (issue #796), so bounding the
* accumulated set by *count* would silently discard older-but-in-window
* messages on the next incremental merge. Bounding by the window floor instead
* keeps exactly what the renderer can display while still preventing unbounded
* growth over a long-running tab. Records whose timestamp is missing or
* non-numeric are retained so data is never lost to a malformed field.
*
* @param {Array<Object>} records Merged record array.
* @param {number} floorSeconds Minimum retained timestamp (unix seconds).
* @param {string} [tsField] Timestamp field name used for comparison.
* @returns {Array<Object>} Filtered array (same reference when nothing is
* dropped or the floor is unusable).
*/
export function trimToWindow(records, floorSeconds, tsField = 'rx_time') {
if (!Array.isArray(records)) return records;
if (!Number.isFinite(floorSeconds) || floorSeconds <= 0) return records;
return records.filter(record => {
const ts = Number(record && record[tsField]);
return !Number.isFinite(ts) || ts >= floorSeconds;
});
}
+47 -8
View File
@@ -98,7 +98,7 @@ import {
aggregateTelemetrySnapshots,
} from './snapshot-aggregator.js';
import { normalizeNodeCollection } from './node-snapshot-normalizer.js';
import { maxRecordTimestamp, mergeById, mergeByCompositeKey, trimToLimit } from './incremental-helpers.js';
import { maxRecordTimestamp, mergeById, mergeByCompositeKey, trimToLimit, trimToWindow } from './incremental-helpers.js';
import { buildTraceSegments } from './trace-paths.js';
import {
getRoleColor,
@@ -169,6 +169,7 @@ import {
filterRecentTraces,
resolveSnapshotLimit,
fetchMessages as fetchMessagesImpl,
fetchAllMessages as fetchAllMessagesImpl,
} from './main/data-fetchers.js';
import {
compareNumber,
@@ -2918,10 +2919,14 @@ export function initializeApp(config) {
: isMeshcoreProtocol(channel.protocol)
? MESHCORE_ICON_SRC
: null,
// Channel tabs are the chat proper: render the entire window (issue #796)
// rather than only the newest CHAT_LIMIT. The entry set is already bounded
// by the seven-day window, so there is no count cap to apply here.
content: buildChatFragment({
entries: channel.entries.map(e => ({ ts: e.ts, item: e.message })),
renderEntry: entry => createMessageChatEntry(entry.item),
emptyLabel: 'No messages on this channel.'
emptyLabel: 'No messages on this channel.',
limit: Infinity
}),
index: channel.index,
isPrimaryFallback: Boolean(channel.isPrimaryFallback)
@@ -2955,11 +2960,14 @@ export function initializeApp(config) {
* @param {{
* entries: Array<{ ts: number, item: Object }>,
* renderEntry: Function,
* emptyLabel?: string
* }} params Fragment construction parameters.
* emptyLabel?: string,
* limit?: number
* }} params Fragment construction parameters. ``limit`` caps how many of the
* newest entries are rendered; pass ``Infinity`` to render them all (the Log
* firehose defaults to {@link CHAT_LIMIT}, chat channel tabs opt out).
* @returns {DocumentFragment} Populated fragment.
*/
function buildChatFragment({ entries = [], renderEntry, emptyLabel }) {
function buildChatFragment({ entries = [], renderEntry, emptyLabel, limit = CHAT_LIMIT }) {
const fragment = document.createDocumentFragment();
if (!entries || entries.length === 0) {
if (emptyLabel) {
@@ -2971,7 +2979,9 @@ export function initializeApp(config) {
return fragment;
}
const getDivider = createDateDividerFactory();
const limitedEntries = entries.slice(Math.max(entries.length - CHAT_LIMIT, 0));
const limitedEntries = Number.isFinite(limit)
? entries.slice(Math.max(entries.length - limit, 0))
: entries;
let renderedEntries = 0;
for (const entry of limitedEntries) {
if (!entry || typeof entry.ts !== 'number') {
@@ -3019,6 +3029,24 @@ export function initializeApp(config) {
});
}
/**
* Closure-bound bridge to ``fetchAllMessagesImpl``. Pages the entire chat
* window (issue #796) so the initial load surfaces every in-window message
* instead of just the newest {@link MESSAGE_LIMIT}. Like {@link fetchMessages}
* it injects the dashboard's ``CHAT_ENABLED`` flag and limit normaliser so the
* underlying pager stays pure.
*
* @param {{ encrypted?: boolean }} [options] Optional retrieval flags.
* @returns {Promise<Array<Object>>} Every message in the visibility window.
*/
function fetchAllMessages(options = {}) {
return fetchAllMessagesImpl(MESSAGE_LIMIT, {
...options,
chatEnabled: CHAT_ENABLED,
normaliseMessageLimit,
});
}
/**
* Compute distance from the configured map center.
*
@@ -3972,7 +4000,12 @@ export function initializeApp(config) {
positionsPromise,
neighborPromise,
tracesPromise,
fetchMessages(MESSAGE_LIMIT, { since: msgSince }),
// First load pages the whole window so chat is complete (issue #796);
// incremental refreshes only need the slice newer than the high-water
// mark, which always fits in a single page.
useSince
? fetchMessages(MESSAGE_LIMIT, { since: msgSince })
: fetchAllMessages({}),
telemetryPromise,
encryptedMessagesPromise
]);
@@ -4011,9 +4044,15 @@ export function initializeApp(config) {
const traceEntries = useSince
? trimToLimit(mergeById(allTraces, incomingTraces, 'id'), TRACE_LIMIT)
: incomingTraces;
// Plaintext chat is shown for the full seven-day window (issue #796), so
// bound the retained set by that window rather than a row count — a count
// cap would silently drop older-but-in-window messages on the next merge.
const messageWindowFloor = Math.floor(Date.now() / 1000) - CHAT_RECENT_WINDOW_SECONDS;
const messages = useSince
? trimToLimit(mergeById(allMessages, incomingMessages, 'id'), MESSAGE_LIMIT)
? trimToWindow(mergeById(allMessages, incomingMessages, 'id'), messageWindowFloor)
: incomingMessages;
// Encrypted blobs only feed the mixed Log tab (itself capped), so a count
// cap is the right memory bound for them.
const encryptedMessages = useSince
? trimToLimit(mergeById(allEncryptedMessages, incomingEncryptedMessages, 'id'), MESSAGE_LIMIT)
: incomingEncryptedMessages;
@@ -18,6 +18,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
fetchAllMessages,
fetchMessages,
fetchNeighbors,
fetchNodeById,
@@ -344,3 +345,107 @@ test('fetchMessages propagates HTTP errors', async () => {
stub.restore();
}
});
test('fetchMessages forwards a positive before cursor and omits a non-positive one', async () => {
const stub = withFetchStub({ ok: true, body: [] });
try {
await fetchMessages(10, { before: 1234 });
assert.ok(stub.calls[0].url.includes('before=1234'));
await fetchMessages(10, { before: 0 });
assert.ok(!stub.calls[1].url.includes('before='));
} finally {
stub.restore();
}
});
// ---------------------------------------------------------------------------
// fetchAllMessages (issue #796 backward pagination)
// ---------------------------------------------------------------------------
test('fetchAllMessages pages backward until a short page and de-duplicates by id', async () => {
// limit=2; the inclusive cursor re-returns the boundary row, which must be
// de-duplicated rather than counted twice.
const stub = withFetchStub((url) => {
if (url.includes('before=40')) {
return { ok: true, body: [{ id: 4, rx_time: 40 }, { id: 3, rx_time: 30 }] };
}
if (url.includes('before=30')) {
return { ok: true, body: [{ id: 3, rx_time: 30 }] }; // short page → stop
}
return { ok: true, body: [{ id: 5, rx_time: 50 }, { id: 4, rx_time: 40 }] };
});
try {
const all = await fetchAllMessages(2, {});
assert.deepEqual(all.map(m => m.id), [5, 4, 3]);
assert.equal(stub.calls.length, 3);
assert.ok(stub.calls[1].url.includes('before=40'));
assert.ok(stub.calls[2].url.includes('before=30'));
} finally {
stub.restore();
}
});
test('fetchAllMessages stops when the server ignores the cursor (no progress)', async () => {
// The stub returns the same full page regardless of the cursor; without the
// no-progress guard this would loop forever.
const stub = withFetchStub({ ok: true, body: [{ id: 5, rx_time: 50 }, { id: 4, rx_time: 40 }] });
try {
const all = await fetchAllMessages(2, {});
assert.deepEqual(all.map(m => m.id), [5, 4]);
assert.equal(stub.calls.length, 2); // page 1 + one no-progress page, then stop
} finally {
stub.restore();
}
});
test('fetchAllMessages returns [] and makes one call for an empty window', async () => {
const stub = withFetchStub({ ok: true, body: [] });
try {
const all = await fetchAllMessages(2, {});
assert.deepEqual(all, []);
assert.equal(stub.calls.length, 1);
} finally {
stub.restore();
}
});
test('fetchAllMessages stops when no row carries a usable timestamp cursor', async () => {
// A full page whose rows lack rx_time cannot advance the cursor; the loop must
// still terminate (and keep the rows it found).
const stub = withFetchStub({ ok: true, body: [{ id: 7 }, { id: 8 }] });
try {
const all = await fetchAllMessages(2, {});
assert.deepEqual(all.map(m => m.id), [7, 8]);
assert.equal(stub.calls.length, 1);
} finally {
stub.restore();
}
});
test('fetchAllMessages skips rows without an id and forwards retrieval flags', async () => {
const stub = withFetchStub({ ok: true, body: [{ rx_time: 40 }] }); // no id → skipped, short page
try {
const all = await fetchAllMessages(2, { encrypted: true });
assert.deepEqual(all, []);
assert.equal(stub.calls.length, 1);
assert.ok(stub.calls[0].url.includes('encrypted=true'));
} finally {
stub.restore();
}
});
test('fetchAllMessages honours the maxPages backstop against a runaway feed', async () => {
// Every page is full and strictly older, so only maxPages bounds the walk.
let n = 0;
const stub = withFetchStub(() => {
n += 1;
return { ok: true, body: [{ id: 100 - n, rx_time: 100 - n }] };
});
try {
const all = await fetchAllMessages(1, { maxPages: 3 });
assert.equal(all.length, 3);
assert.equal(stub.calls.length, 3);
} finally {
stub.restore();
}
});
+57 -2
View File
@@ -102,13 +102,15 @@ export async function fetchNodeById(nodeId) {
* Fetch recent messages from the JSON API.
*
* @param {number} limit Maximum number of rows.
* @param {{ encrypted?: boolean, since?: number, chatEnabled?: boolean, normaliseMessageLimit?: Function }} options
* @param {{ encrypted?: boolean, since?: number, before?: number, chatEnabled?: boolean, normaliseMessageLimit?: Function }} options
* Retrieval flags and dependency hooks. When ``chatEnabled`` is false the
* function short-circuits to an empty array without contacting the API.
* ``before`` is an inclusive upper-bound ``rx_time`` cursor used for backward
* pagination (issue #796).
* @returns {Promise<Array<Object>>} Parsed message payloads.
*/
export async function fetchMessages(limit, options = {}) {
const { chatEnabled = true, normaliseMessageLimit, encrypted = false, since = 0 } = options;
const { chatEnabled = true, normaliseMessageLimit, encrypted = false, since = 0, before = 0 } = options;
if (!chatEnabled) return [];
const safeLimit = typeof normaliseMessageLimit === 'function'
? normaliseMessageLimit(limit)
@@ -120,12 +122,65 @@ export async function fetchMessages(limit, options = {}) {
if (since > 0) {
params.set('since', String(since));
}
if (before > 0) {
params.set('before', String(before));
}
const query = params.toString();
const r = await fetch(`/api/messages?${query}`, { cache: 'default' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
/**
* Fetch *every* message in the server's visibility window by paging backward.
*
* The API clamps each response to {@link MESSAGE_LIMIT} rows, so a single
* request can only ever surface the newest page. This helper walks the feed
* from newest to oldest: it pulls a page, then re-requests everything at or
* before the oldest ``rx_time`` it has seen (an inclusive cursor), de-duplicating
* by ``id``. It stops when a short page signals the window is exhausted, when a
* page yields no new rows (the server ignored the cursor, or every row was a
* boundary duplicate), or when ``maxPages`` is hit as a runaway backstop.
*
* Used for the initial chat load (issue #796) so the landing page and ``/chat``
* subpage show the full window instead of only the newest page.
*
* @param {number} limit Page size (rows requested per API call).
* @param {{ encrypted?: boolean, chatEnabled?: boolean, normaliseMessageLimit?: Function, maxPages?: number }} [options]
* Retrieval flags, dependency hooks, and an optional page-count backstop.
* @returns {Promise<Array<Object>>} All de-duplicated messages in the window.
*/
export async function fetchAllMessages(limit, options = {}) {
const { maxPages = 200, ...fetchOptions } = options;
const all = [];
const seen = new Set();
let before = 0;
for (let page = 0; page < maxPages; page += 1) {
// eslint-disable-next-line no-await-in-loop -- pages are inherently sequential (cursor depends on the prior page).
const batch = await fetchMessages(limit, { ...fetchOptions, before });
if (!Array.isArray(batch) || batch.length === 0) break;
let added = 0;
let oldest = 0;
for (const message of batch) {
const id = message && message.id;
if (id != null && !seen.has(id)) {
seen.add(id);
all.push(message);
added += 1;
}
const ts = Number(message && message.rx_time);
if (Number.isFinite(ts) && (oldest === 0 || ts < oldest)) {
oldest = ts;
}
}
// A short page means the window is exhausted; no new rows (or no usable
// cursor) means we cannot make further progress without looping forever.
if (batch.length < limit || added === 0 || oldest === 0) break;
before = oldest;
}
return all;
}
/**
* Fetch neighbour information from the JSON API.
*
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { isMeshcoreProtocol } from './protocol-helpers.js';
/**
* Parse the ``"SenderName: body"`` prefix that MeshCore embeds in channel
* message text. MeshCore channel messages do not carry a sender node ID, so
@@ -37,6 +39,24 @@ export function parseMeshcoreSenderPrefix(text) {
return { senderName, bodyText };
}
/**
* Whether a candidate node belongs to the same protocol as the message that
* referenced it. Node and message protocols are reduced to the two canonical
* values (anything not explicitly MeshCore including absent/unknown is the
* Meshtastic default), so a MeshCore message never matches a Meshtastic (or
* unstamped) node and vice-versa. A ``null`` requested protocol disables the
* filter, preserving the original protocol-agnostic behaviour for callers that
* have no protocol context.
*
* @param {Object} node Candidate node record.
* @param {string|null|undefined} protocol Protocol of the referencing message.
* @returns {boolean} Whether the node may be matched for this protocol.
*/
function nodeMatchesProtocol(node, protocol) {
if (protocol == null) return true;
return isMeshcoreProtocol(protocol) === isMeshcoreProtocol(node && node.protocol);
}
/**
* Look up a node in the provided ``nodesById`` Map by its long name.
*
@@ -47,6 +67,13 @@ export function parseMeshcoreSenderPrefix(text) {
* Both the snake_case (``long_name``) and camelCase (``longName``) property
* variants are checked to accommodate different serialisation paths.
*
* When ``protocol`` is supplied, only nodes of that protocol are eligible.
* Long names collide across protocols (a MeshCore and a Meshtastic node can
* both be called "Timo"), so without this filter the scan would return whichever
* node happens to come first in insertion order letting a MeshCore message
* quote a Meshtastic node. Filtering by the referencing message's protocol is
* what keeps chat resolution protocol-correct.
*
* This is an O(n) scan over all nodes. For the typical node counts seen in
* practice (hundreds) this is negligible; a long-name index is not maintained
* in the client-side Map because insertions and lookups occur at different
@@ -54,9 +81,12 @@ export function parseMeshcoreSenderPrefix(text) {
*
* @param {string} longName Long name to search for.
* @param {Map<string, object>} nodesById Loaded node registry keyed by node ID.
* @returns {object|null} The first matching node, or ``null`` when not found.
* @param {string|null} [protocol] Protocol the matched node must belong to;
* ``null``/omitted matches any protocol (legacy behaviour).
* @returns {object|null} The first matching node of the requested protocol, or
* ``null`` when not found.
*/
export function findNodeByLongName(longName, nodesById) {
export function findNodeByLongName(longName, nodesById, protocol = null) {
if (!longName || typeof longName !== 'string') return null;
if (!(nodesById instanceof Map)) return null;
const trimmed = longName.trim();
@@ -69,6 +99,7 @@ export function findNodeByLongName(longName, nodesById) {
// First pass: exact match on trimmed candidate long names.
for (const node of nodesById.values()) {
if (!nodeMatchesProtocol(node, protocol)) continue;
const raw = node.long_name ?? node.longName;
if (typeof raw !== 'string') continue;
if (raw.trim() === trimmed) return node;
@@ -80,6 +111,7 @@ export function findNodeByLongName(longName, nodesById) {
// prefix the node carries in the registry — e.g. @[Timo +] matching
// a node whose long_name is "📺 Timo +".
for (const node of nodesById.values()) {
if (!nodeMatchesProtocol(node, protocol)) continue;
const raw = node.long_name ?? node.longName;
if (typeof raw !== 'string') continue;
const stripped = raw.replace(/^[^\p{L}\p{N}]+/u, '').trim();
@@ -89,6 +121,32 @@ export function findNodeByLongName(longName, nodesById) {
return null;
}
/**
* Build a synthetic stand-in node for a chat name reference that no
* same-protocol registry node matched.
*
* Carrying the referencing message's protocol keeps the rendered badge's colour
* palette and protocol icon correct and crucially guarantees a MeshCore
* message renders a MeshCore-stamped badge instead of borrowing a colliding
* node from another protocol. This mirrors the protocol-stamped placeholder
* the message hydrator builds for unknown senders
* (``message-node-hydrator``), but is keyed on the visible name rather than a
* node id because mentions/quotes reference nodes by name.
*
* The visible name is used as both the short and long name so the badge stays
* legible (a bare ``long_name`` would render as a ``?`` placeholder).
*
* @param {string} name Visible name parsed from the message (mention/sender).
* @param {string|null|undefined} protocol Protocol of the referencing message.
* @returns {{short_name: string, long_name: string, protocol?: string}}
* Synthetic node ready for badge rendering.
*/
export function buildSyntheticChatNode(name, protocol) {
const node = { short_name: name, long_name: name };
if (protocol != null) node.protocol = protocol;
return node;
}
/**
* Extract a leading ``@[Name]`` mention from text if it looks like a reply.
*
+75
View File
@@ -6533,6 +6533,81 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(scoped_since.map { |row| row["id"] }).to eq([2])
end
# Regression for issue #796: more than MAX_QUERY_LIMIT messages inside the
# seven-day window must all remain reachable. Before the fix the feed had
# no upper-bound cursor, so paging stalled at the newest 1000 rows and every
# older in-window message was invisible.
it "exposes every in-window message through backward pagination (issue #796)" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
cap = PotatoMesh::App::Queries::MAX_QUERY_LIMIT
total = cap + 500
# Seed more than one page of messages, all comfortably inside the
# seven-day window, each with a distinct rx_time so the keyset cursor is
# unambiguous.
with_db do |db|
db.transaction do
total.times do |i|
rx = now - 60 - i * 30
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text) VALUES(?,?,?,?,?,?,?,?)",
[1000 + i, rx, Time.at(rx).utc.iso8601, "!a", "!b", 0, "TEXT_MESSAGE_APP", "msg #{i}"],
)
end
end
end
# Walk the feed the way the dashboard client does: pull a page, then ask
# for everything at-or-before the oldest row already seen. Without an
# upper-bound cursor the server cannot return anything past the newest
# `cap` rows, so the loop stalls and never reaches the older messages.
seen = {}
cursor = nil
pages = 0
loop do
url = "/api/messages?limit=#{cap}"
url += "&before=#{cursor}" if cursor
get url
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
# The per-request cap is unchanged: a single response never exceeds it.
expect(rows.size).to be <= cap
added = rows.reject { |row| seen.key?(row["id"]) }
added.each { |row| seen[row["id"]] = true }
pages += 1
break if rows.size < cap # window exhausted
break if added.empty? # no progress (unfixed server ignores `before`)
break if pages >= 10 # hard safety bound against an infinite loop
cursor = rows.map { |row| row["rx_time"] }.min
end
expect(seen.size).to eq(total)
end
it "treats a non-positive before cursor as absent (issue #796)" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES(?,?,?,?,?,?,?)",
[42, now - 30, Time.at(now - 30).utc.iso8601, "!a", "!b", 0, "hi"],
)
end
# before=0 and before=-5 must be ignored (not used as a ceiling), so the
# row still comes back rather than being filtered out by a bogus cursor.
get "/api/messages?before=0"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body).map { |r| r["id"] }).to eq([42])
get "/api/messages?before=-5"
expect(last_response).to be_ok
expect(JSON.parse(last_response.body).map { |r| r["id"] }).to eq([42])
end
it "clamps an explicit since older than the seven-day floor up to the floor" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
+24
View File
@@ -677,6 +677,30 @@ RSpec.describe PotatoMesh::App::Queries do
expect(scoped_ids).to include(101)
expect(scoped_ids).to include(102)
end
it "applies an inclusive before cursor and ignores a non-positive one (issue #796)" do
with_db do |db|
[10, 20, 30].each do |offset|
rx = now - offset
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[200 + offset, rx, Time.at(rx).utc.iso8601, "!aabbccdd", "!ffffffff", 0, "m#{offset}"],
)
end
end
# Inclusive ceiling: the row exactly at the cursor stays; newer rows drop.
# This is what lets the client page backward by feeding the oldest rx_time
# of each page as the next cursor without skipping boundary-second rows.
paged = queries.query_messages(10, before: now - 20).map { |r| r["id"] }
expect(paged).to include(220, 230)
expect(paged).not_to include(210) # newer than the cursor
expect(paged).not_to include(1) # base row at `now` is newer than the cursor
# A non-positive cursor is treated as "no cursor" — the default window.
unbounded = queries.query_messages(10, before: 0).map { |r| r["id"] }
expect(unbounded).to include(1, 210, 220, 230)
end
end
describe "#query_telemetry" do