diff --git a/cmd/server/db.go b/cmd/server/db.go index edbae522..35fac20d 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1919,13 +1919,21 @@ func (db *DB) GetEncryptedChannels(region ...string) ([]map[string]interface{}, // a bare "ping". var channelMentionPrefixRe = regexp.MustCompile(`^@[A-Za-z0-9_-]{1,32}\s+`) +// pingTriggerWords are the exact (case-insensitive) message bodies that +// trigger a pong reply. Mirrored by pingTriggerWords in +// public/channels.js -- keep both lists in sync by hand. +var pingTriggerWords = map[string]bool{ + "ping": true, + "/ping": true, +} + // isPingTrigger reports whether displayText, after stripping a leading // "@target " mention the same way the frontend does (public/channels.js -// replyMatch), is exactly "ping". +// replyMatch), exactly matches one of pingTriggerWords. func isPingTrigger(displayText string) bool { trigger := strings.TrimSpace(displayText) trigger = channelMentionPrefixRe.ReplaceAllString(trigger, "") - return strings.EqualFold(strings.TrimSpace(trigger), "ping") + return pingTriggerWords[strings.ToLower(strings.TrimSpace(trigger))] } // pingBotReply synthesizes a "pong" reply for a channel message whose diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index caec75a4..0ef405b9 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -1527,12 +1527,19 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (4, 1, 3.0, -99, '[]', 1736935380)`) + // tx5: "/ping" -- the slash-command form must trigger too. + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('EE', 'chanmsg00000005', '2026-01-15T10:04:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"/ping","sender":"Frank"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (5, 1, 6.0, -91, '["aa"]', 1736935440)`) + messages, total, err := db.GetChannelMessages("#ping", 100, 0) if err != nil { t.Fatal(err) } - if total != 4 { - t.Fatalf("expected 4 messages, got %d", total) + if total != 5 { + t.Fatalf("expected 5 messages, got %d", total) } byText := map[string]map[string]interface{}{} @@ -1572,6 +1579,14 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { if mentionReply["hops"] != 0 { t.Errorf("mention-prefixed ping botReply hops = %v, want 0 (empty path)", mentionReply["hops"]) } + + slashReply, _ := byText["/ping"]["botReply"].(map[string]interface{}) + if slashReply == nil { + t.Fatal("\"/ping\" should get a botReply -- it's in pingTriggerWords alongside bare \"ping\"") + } + if slashReply["sender"] != "CoreScopeBot" { + t.Errorf("\"/ping\" botReply sender = %v, want CoreScopeBot", slashReply["sender"]) + } } // TestGetChannelMessages_PingBotReply_MultiObservation covers a single diff --git a/public/channels.js b/public/channels.js index fe8c19c0..9b62d70a 100644 --- a/public/channels.js +++ b/public/channels.js @@ -335,9 +335,12 @@ // path (repeater names) -- the live WS broadcast doesn't carry a // per-packet resolved_path, only REST-loaded history does (via // GetChannelMessages). scope/area ARE available live and are included. + // pingTriggerWords mirrors pingTriggerWords in cmd/server/db.go -- keep + // both lists in sync by hand. + var pingTriggerWords = { 'ping': true, '/ping': true }; function pingBotReply(text, hops, snr, observer, scope, area) { var trigger = String(text || '').trim().replace(/^@[A-Za-z0-9_-]{1,32}\s+/, '').trim(); - if (trigger.toLowerCase() !== 'ping') return null; + if (!pingTriggerWords[trigger.toLowerCase()]) return null; var parts = [hops > 0 ? (hops + ' hop' + (hops === 1 ? '' : 's')) : '0 hops (direct)']; if (snr !== null && snr !== undefined) parts.push('SNR ' + Number(snr).toFixed(1) + 'dB'); if (observer) parts.push('heard by ' + observer); @@ -2324,7 +2327,8 @@ const safeId = btoa(encodeURIComponent(sender)); // Ping-bot reply (server-synthesized in GetChannelMessages when this - // message's text is exactly "ping" -- see pingBotReply in db.go). + // message's text matches a trigger word (pingTriggerWords) -- see + // pingBotReply in db.go). // CoreScope-only: never transmitted back onto the mesh, since // CoreScope has no publish path to a MeshCore broker/radio. The // "Not sent to the mesh" caveat is load-bearing, not decoration -- diff --git a/test-channels-ping-bot-reply.js b/test-channels-ping-bot-reply.js index 565d6b2d..30206d26 100644 --- a/test-channels-ping-bot-reply.js +++ b/test-channels-ping-bot-reply.js @@ -86,6 +86,14 @@ test('exact "ping" (any case) triggers a reply', () => { assert.ok(fn(' ping ', 1, 5, 'Obs') !== null, 'surrounding whitespace should be trimmed'); }); +test('"/ping" (the slash-command form) also triggers, alongside bare "ping"', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + assert.ok(fn('/ping', 1, 5, 'Obs') !== null); + assert.ok(fn('/PING', 1, 5, 'Obs') !== null, 'case-insensitive like the bare form'); + assert.strictEqual(fn('/pingx', 1, 5, 'Obs'), null, 'still an exact match, not a prefix match'); +}); + test('a mention prefix like "@CoreScopeBot ping" is stripped before matching', () => { const { ctx } = makeSandbox(); const fn = ctx.window._channelsPingBotReplyForTest;