feat: ping-bot also triggers on "/ping", not just bare "ping"

Trigger check moved from a single string comparison to a small
pingTriggerWords set (mirrored by hand in db.go and channels.js), so
adding more trigger words later is a one-line change in each. Still an
exact match after the existing @mention-stripping -- "/pingx" etc. don't
match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-23 15:52:08 +02:00
co-authored by Claude Sonnet 5
parent 5842d85abd
commit 653949479a
4 changed files with 41 additions and 6 deletions
+10 -2
View File
@@ -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
+17 -2
View File
@@ -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
+6 -2
View File
@@ -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 --
+8
View File
@@ -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;